mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-05-24 17:19:55 +00:00
Compare commits
178 Commits
feat/3d_la
...
7df67ea9f3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7df67ea9f3 | ||
|
|
4d3a41ed0d | ||
|
|
56d25b88bd | ||
|
|
95f3e0b291 | ||
|
|
9b706236f6 | ||
|
|
9f60e65b6d | ||
|
|
59aa991988 | ||
|
|
aff340de84 | ||
|
|
2fd4270831 | ||
|
|
0d41d83ce5 | ||
|
|
9a6f744afd | ||
|
|
8164d990cc | ||
|
|
5c9c8a4ee9 | ||
|
|
68ef739f4a | ||
|
|
29a484f16f | ||
|
|
a48985720c | ||
|
|
14cf4ddc0d | ||
|
|
ad66fc1841 | ||
|
|
6b3f9756a0 | ||
|
|
afddc6e40c | ||
|
|
edd67e4880 | ||
|
|
d13d3f7dfe | ||
|
|
1ab1ed69d4 | ||
|
|
ad2e5a1c04 | ||
|
|
71d35d31af | ||
|
|
7f4b57f589 | ||
|
|
0c667e68e6 | ||
|
|
9430be51a4 | ||
|
|
a187a57430 | ||
|
|
68029217de | ||
|
|
792504e08c | ||
|
|
04c0564366 | ||
|
|
9d65718f37 | ||
|
|
35bcf6765d | ||
|
|
cdbca70222 | ||
|
|
1a267729e4 | ||
|
|
b11f6eac55 | ||
|
|
d85ff540c4 | ||
|
|
5f45a0b81b | ||
|
|
6bf9a319c7 | ||
|
|
74f0d5ee65 | ||
|
|
2596d48a2f | ||
|
|
2ac1a3242a | ||
|
|
5d208c832b | ||
|
|
786498904d | ||
|
|
a9ea9f425d | ||
|
|
b3bc951cae | ||
|
|
01df4f1115 | ||
|
|
ca985f92ab | ||
|
|
41be9e4e19 | ||
|
|
e1074f06d2 | ||
|
|
0dc273f366 | ||
|
|
2e5fac26b3 | ||
|
|
5c2da9b793 | ||
|
|
45efbfcd12 | ||
|
|
8da6fdfd0b | ||
|
|
29ea9909a5 | ||
|
|
f38f3dfc89 | ||
|
|
ee6307a568 | ||
|
|
8a0116c852 | ||
|
|
d3f59913b0 | ||
|
|
f6d46e669d | ||
|
|
abf5555e37 | ||
|
|
e4d915c59c | ||
|
|
11a38d4558 | ||
|
|
aeeb36d075 | ||
|
|
3478bfd7ed | ||
|
|
d6910da57d | ||
|
|
d5b4f07406 | ||
|
|
470d7283e4 | ||
|
|
03f7f44c77 | ||
|
|
6f600b4fc7 | ||
|
|
269ce440d1 | ||
|
|
be054589b5 | ||
|
|
b045ab4e0a | ||
|
|
4595f86725 | ||
|
|
44a4c2362d | ||
|
|
1340bae838 | ||
|
|
ae75f07c8e | ||
|
|
18d0ba7a46 | ||
|
|
de7fbe7ac8 | ||
|
|
31e8d065c4 | ||
|
|
219a480c08 | ||
|
|
e9f1a7bb44 | ||
|
|
ead43b2bc1 | ||
|
|
cef86fd98d | ||
|
|
6993e97ae9 | ||
|
|
db396bcab3 | ||
|
|
1fed8de57d | ||
|
|
63eb0c0a4c | ||
|
|
888c6cf542 | ||
|
|
cc248fc32c | ||
|
|
cfe64b023b | ||
|
|
ad1312cf26 | ||
|
|
799813f85b | ||
|
|
19c9d655d0 | ||
|
|
f9a9e35269 | ||
|
|
8cd306cd32 | ||
|
|
816a0d747b | ||
|
|
b0cff1a7a8 | ||
|
|
71d57c5631 | ||
|
|
546fb633ec | ||
|
|
a3c7fa9385 | ||
|
|
c6cf84def0 | ||
|
|
86512a0482 | ||
|
|
3ddbc1c9b7 | ||
|
|
abf1005241 | ||
|
|
c475eabb60 | ||
|
|
3ad20c85a5 | ||
|
|
44fc80c70f | ||
|
|
8ba911bb55 | ||
|
|
896f287d92 | ||
|
|
0d150f7acd | ||
|
|
c27f7e42d6 | ||
|
|
cc56a68bc6 | ||
|
|
d7302c3b35 | ||
|
|
b46a51c40e | ||
|
|
c6780087b8 | ||
|
|
1ef698dde6 | ||
|
|
91aadba4ef | ||
|
|
b1cdef9185 | ||
|
|
9854ed8c9c | ||
|
|
52544a2c69 | ||
|
|
5ce433e235 | ||
|
|
c7c14d2332 | ||
|
|
6fdd482649 | ||
|
|
d390236318 | ||
|
|
ed8ee29732 | ||
|
|
ffc583e9d5 | ||
|
|
f1ad0c9c96 | ||
|
|
8fa3407649 | ||
|
|
d3282822fc | ||
|
|
554bcade24 | ||
|
|
a662c75de1 | ||
|
|
931614fe64 | ||
|
|
d39662f65f | ||
|
|
acf5fdebf8 | ||
|
|
7f7b1c13c0 | ||
|
|
75f09034ff | ||
|
|
549a50220b | ||
|
|
4189a2cfbe | ||
|
|
48895a9bb1 | ||
|
|
891f126ed6 | ||
|
|
4d3475a849 | ||
|
|
b475db66df | ||
|
|
a625a86e3e | ||
|
|
37e0f1037c | ||
|
|
a242253145 | ||
|
|
448e0074b7 | ||
|
|
304827fc8d | ||
|
|
872b3d781f | ||
|
|
813400f2b4 | ||
|
|
b6dfe2b944 | ||
|
|
8807865649 | ||
|
|
5fc7eb7586 | ||
|
|
9bd72b48e1 | ||
|
|
42b78ab4c1 | ||
|
|
9645609a05 | ||
|
|
a2a827d7ac | ||
|
|
bb3ca645a4 | ||
|
|
37ee43d19a | ||
|
|
bc30f23e34 | ||
|
|
166d84afe1 | ||
|
|
1b43c53015 | ||
|
|
d4415f5a35 | ||
|
|
0260cbbedb | ||
|
|
7c440d10ab | ||
|
|
c85c49817d | ||
|
|
c70eafa5f0 | ||
|
|
b64466d443 | ||
|
|
ef3f24ed48 | ||
|
|
2a8e8d014b | ||
|
|
e0da1c7217 | ||
|
|
51d3e61723 | ||
|
|
6b5765bbf3 | ||
|
|
eb1f3fbe1c | ||
|
|
fb93b1cd94 | ||
|
|
9aeffebde1 |
9
.conda/scripts/post-link.bat
Normal file
9
.conda/scripts/post-link.bat
Normal file
@@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM upgrade pip
|
||||
"%PREFIX%\python.exe" -m pip install --upgrade pip
|
||||
|
||||
REM install extra deps
|
||||
"%PREFIX%\python.exe" -m pip install paho-mqtt opentrons_shared_data
|
||||
"%PREFIX%\python.exe" -m pip install git+https://github.com/Xuwznln/pylabrobot.git
|
||||
9
.conda/scripts/post-link.sh
Normal file
9
.conda/scripts/post-link.sh
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euxo pipefail
|
||||
|
||||
# make sure pip is available
|
||||
"$PREFIX/bin/python" -m pip install --upgrade pip
|
||||
|
||||
# install extra deps
|
||||
"$PREFIX/bin/python" -m pip install paho-mqtt opentrons_shared_data
|
||||
"$PREFIX/bin/python" -m pip install git+https://github.com/Xuwznln/pylabrobot.git
|
||||
26
.cursorignore
Normal file
26
.cursorignore
Normal file
@@ -0,0 +1,26 @@
|
||||
.conda
|
||||
# .github
|
||||
.idea
|
||||
# .vscode
|
||||
output
|
||||
pylabrobot_repo
|
||||
recipes
|
||||
scripts
|
||||
service
|
||||
temp
|
||||
# unilabos/test
|
||||
# unilabos/app/web
|
||||
unilabos/device_mesh
|
||||
unilabos_data
|
||||
unilabos_msgs
|
||||
unilabos.egg-info
|
||||
CONTRIBUTORS
|
||||
# LICENSE
|
||||
MANIFEST.in
|
||||
pyrightconfig.json
|
||||
# README.md
|
||||
# README_zh.md
|
||||
setup.py
|
||||
setup.cfg
|
||||
.gitattrubutes
|
||||
**/__pycache__
|
||||
19
.github/dependabot.yml
vendored
19
.github/dependabot.yml
vendored
@@ -1,19 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
target-branch: "dev"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "06:00"
|
||||
open-pull-requests-limit: 5
|
||||
reviewers:
|
||||
- "msgcenterpy-team"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
include: "scope"
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -252,3 +252,5 @@ ros-humble-unilabos-msgs-0.9.13-h6403a04_5.tar.bz2
|
||||
test_config.py
|
||||
|
||||
|
||||
/.claude
|
||||
/.cursor
|
||||
|
||||
1315
docs/moveit2_integration_summary.md
Normal file
1315
docs/moveit2_integration_summary.md
Normal file
File diff suppressed because it is too large
Load Diff
539
test/devices/test_prcxi.py
Normal file
539
test/devices/test_prcxi.py
Normal file
@@ -0,0 +1,539 @@
|
||||
import pytest
|
||||
import json
|
||||
import os
|
||||
import asyncio
|
||||
import collections
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from pylabrobot.resources import Coordinate
|
||||
from pylabrobot.resources.opentrons.tip_racks import opentrons_96_tiprack_300ul, opentrons_96_tiprack_10ul
|
||||
from pylabrobot.resources.opentrons.plates import corning_96_wellplate_360ul_flat, nest_96_wellplate_2ml_deep
|
||||
|
||||
from unilabos.devices.liquid_handling.prcxi.prcxi import (
|
||||
PRCXI9300Deck,
|
||||
PRCXI9300Container,
|
||||
PRCXI9300Trash,
|
||||
PRCXI9300Handler,
|
||||
PRCXI9300Backend,
|
||||
DefaultLayout,
|
||||
Material,
|
||||
WorkTablets,
|
||||
MatrixInfo
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prcxi_materials() -> Dict[str, Any]:
|
||||
"""加载 PRCXI 物料数据"""
|
||||
print("加载 PRCXI 物料数据...")
|
||||
material_path = os.path.join(os.path.dirname(__file__), "..", "..", "unilabos", "devices", "liquid_handling", "prcxi", "prcxi_material.json")
|
||||
with open(material_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
print(f"加载了 {len(data)} 条物料数据")
|
||||
return data
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prcxi_9300_deck() -> PRCXI9300Deck:
|
||||
"""创建 PRCXI 9300 工作台"""
|
||||
return PRCXI9300Deck(name="PRCXI_Deck_9300", size_x=100, size_y=100, size_z=100, model="9300")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prcxi_9320_deck() -> PRCXI9300Deck:
|
||||
"""创建 PRCXI 9320 工作台"""
|
||||
return PRCXI9300Deck(name="PRCXI_Deck_9320", size_x=100, size_y=100, size_z=100, model="9320")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prcxi_9300_handler(prcxi_9300_deck) -> PRCXI9300Handler:
|
||||
"""创建 PRCXI 9300 处理器(模拟模式)"""
|
||||
return PRCXI9300Handler(
|
||||
deck=prcxi_9300_deck,
|
||||
host="192.168.1.201",
|
||||
port=9999,
|
||||
timeout=10.0,
|
||||
channel_num=8,
|
||||
axis="Left",
|
||||
setup=False,
|
||||
debug=True,
|
||||
simulator=True,
|
||||
matrix_id="test-matrix-9300"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prcxi_9320_handler(prcxi_9320_deck) -> PRCXI9300Handler:
|
||||
"""创建 PRCXI 9320 处理器(模拟模式)"""
|
||||
return PRCXI9300Handler(
|
||||
deck=prcxi_9320_deck,
|
||||
host="192.168.1.201",
|
||||
port=9999,
|
||||
timeout=10.0,
|
||||
channel_num=1,
|
||||
axis="Right",
|
||||
setup=False,
|
||||
debug=True,
|
||||
simulator=True,
|
||||
matrix_id="test-matrix-9320",
|
||||
is_9320=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tip_rack_300ul(prcxi_materials) -> PRCXI9300Container:
|
||||
"""创建 300μL 枪头盒"""
|
||||
tip_rack = PRCXI9300Container(
|
||||
name="tip_rack_300ul",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="tip_rack",
|
||||
ordering=collections.OrderedDict()
|
||||
)
|
||||
tip_rack.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["300μL Tip头"]["uuid"],
|
||||
"Code": "ZX-001-300",
|
||||
"Name": "300μL Tip头"
|
||||
}
|
||||
})
|
||||
return tip_rack
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tip_rack_10ul(prcxi_materials) -> PRCXI9300Container:
|
||||
"""创建 10μL 枪头盒"""
|
||||
tip_rack = PRCXI9300Container(
|
||||
name="tip_rack_10ul",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="tip_rack",
|
||||
ordering=collections.OrderedDict()
|
||||
)
|
||||
tip_rack.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["10μL加长 Tip头"]["uuid"],
|
||||
"Code": "ZX-001-10+",
|
||||
"Name": "10μL加长 Tip头"
|
||||
}
|
||||
})
|
||||
return tip_rack
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def well_plate_96(prcxi_materials) -> PRCXI9300Container:
|
||||
"""创建 96 孔板"""
|
||||
plate = PRCXI9300Container(
|
||||
name="well_plate_96",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="plate",
|
||||
ordering=collections.OrderedDict()
|
||||
)
|
||||
plate.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["96深孔板"]["uuid"],
|
||||
"Code": "ZX-019-2.2",
|
||||
"Name": "96深孔板"
|
||||
}
|
||||
})
|
||||
return plate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deep_well_plate(prcxi_materials) -> PRCXI9300Container:
|
||||
"""创建深孔板"""
|
||||
plate = PRCXI9300Container(
|
||||
name="deep_well_plate",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="plate",
|
||||
ordering=collections.OrderedDict()
|
||||
)
|
||||
plate.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["96深孔板"]["uuid"],
|
||||
"Code": "ZX-019-2.2",
|
||||
"Name": "96深孔板"
|
||||
}
|
||||
})
|
||||
return plate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trash_container(prcxi_materials) -> PRCXI9300Trash:
|
||||
"""创建垃圾桶"""
|
||||
trash = PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
|
||||
trash.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["废弃槽"]["uuid"]
|
||||
}
|
||||
})
|
||||
return trash
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_layout_9300() -> DefaultLayout:
|
||||
"""创建 PRCXI 9300 默认布局"""
|
||||
return DefaultLayout("PRCXI9300")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_layout_9320() -> DefaultLayout:
|
||||
"""创建 PRCXI 9320 默认布局"""
|
||||
return DefaultLayout("PRCXI9320")
|
||||
|
||||
|
||||
class TestPRCXIDeckSetup:
|
||||
"""测试 PRCXI 工作台设置功能"""
|
||||
|
||||
def test_prcxi_9300_deck_creation(self, prcxi_9300_deck):
|
||||
"""测试 PRCXI 9300 工作台创建"""
|
||||
assert prcxi_9300_deck.name == "PRCXI_Deck_9300"
|
||||
assert len(prcxi_9300_deck.sites) == 6
|
||||
assert prcxi_9300_deck._size_x == 100
|
||||
assert prcxi_9300_deck._size_y == 100
|
||||
assert prcxi_9300_deck._size_z == 100
|
||||
|
||||
def test_prcxi_9320_deck_creation(self, prcxi_9320_deck):
|
||||
"""测试 PRCXI 9320 工作台创建"""
|
||||
assert prcxi_9320_deck.name == "PRCXI_Deck_9320"
|
||||
assert len(prcxi_9320_deck.sites) == 16
|
||||
assert prcxi_9320_deck._size_x == 100
|
||||
assert prcxi_9320_deck._size_y == 100
|
||||
assert prcxi_9320_deck._size_z == 100
|
||||
|
||||
def test_container_assignment(self, prcxi_9300_deck, tip_rack_300ul, well_plate_96, trash_container):
|
||||
"""测试容器分配到工作台"""
|
||||
# 分配枪头盒
|
||||
prcxi_9300_deck.assign_child_resource(tip_rack_300ul, location=Coordinate(0, 0, 0))
|
||||
assert tip_rack_300ul in prcxi_9300_deck.children
|
||||
|
||||
# 分配孔板
|
||||
prcxi_9300_deck.assign_child_resource(well_plate_96, location=Coordinate(0, 0, 0))
|
||||
assert well_plate_96 in prcxi_9300_deck.children
|
||||
|
||||
# 分配垃圾桶
|
||||
prcxi_9300_deck.assign_child_resource(trash_container, location=Coordinate(0, 0, 0))
|
||||
assert trash_container in prcxi_9300_deck.children
|
||||
|
||||
def test_container_material_loading(self, tip_rack_300ul, well_plate_96, prcxi_materials):
|
||||
"""测试容器物料信息加载"""
|
||||
# 测试枪头盒物料信息
|
||||
tip_material = tip_rack_300ul._unilabos_state["Material"]
|
||||
assert tip_material["uuid"] == prcxi_materials["300μL Tip头"]["uuid"]
|
||||
assert tip_material["Name"] == "300μL Tip头"
|
||||
|
||||
# 测试孔板物料信息
|
||||
plate_material = well_plate_96._unilabos_state["Material"]
|
||||
assert plate_material["uuid"] == prcxi_materials["96深孔板"]["uuid"]
|
||||
assert plate_material["Name"] == "96深孔板"
|
||||
|
||||
|
||||
class TestPRCXISingleStepOperations:
|
||||
"""测试 PRCXI 单步操作功能"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_up_tips_single_channel(self, prcxi_9320_handler, prcxi_9320_deck, tip_rack_10ul):
|
||||
"""测试单通道拾取枪头"""
|
||||
# 将枪头盒添加到工作台
|
||||
prcxi_9320_deck.assign_child_resource(tip_rack_10ul, location=Coordinate(0, 0, 0))
|
||||
|
||||
# 初始化处理器
|
||||
await prcxi_9320_handler.setup()
|
||||
|
||||
# 设置枪头盒
|
||||
prcxi_9320_handler.set_tiprack([tip_rack_10ul])
|
||||
|
||||
# 创建模拟的枪头位置
|
||||
from pylabrobot.resources import TipSpot, Tip
|
||||
tip = Tip(has_filter=False, total_tip_length=10, maximal_volume=10, fitting_depth=5)
|
||||
tip_spot = TipSpot("A1", size_x=1, size_y=1, size_z=1, make_tip=lambda: tip)
|
||||
tip_rack_10ul.assign_child_resource(tip_spot, location=Coordinate(0, 0, 0))
|
||||
|
||||
# 直接测试后端方法
|
||||
from pylabrobot.liquid_handling import Pickup
|
||||
pickup = Pickup(resource=tip_spot, offset=None, tip=tip)
|
||||
await prcxi_9320_handler._unilabos_backend.pick_up_tips([pickup], [0])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "Load"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pick_up_tips_multi_channel(self, prcxi_9300_handler, tip_rack_300ul):
|
||||
"""测试多通道拾取枪头"""
|
||||
# 设置枪头盒
|
||||
prcxi_9300_handler.set_tiprack([tip_rack_300ul])
|
||||
|
||||
# 拾取8个枪头
|
||||
tip_spots = tip_rack_300ul.children[:8]
|
||||
await prcxi_9300_handler.pick_up_tips(tip_spots, [0, 1, 2, 3, 4, 5, 6, 7])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9300_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9300_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "Load"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aspirate_single_channel(self, prcxi_9320_handler, well_plate_96):
|
||||
"""测试单通道吸取液体"""
|
||||
# 设置液体
|
||||
well = well_plate_96.get_item("A1")
|
||||
prcxi_9320_handler.set_liquid([well], ["water"], [50])
|
||||
|
||||
# 吸取液体
|
||||
await prcxi_9320_handler.aspirate([well], [50], [0])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "Imbibing"
|
||||
assert step["DosageNum"] == 50
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispense_single_channel(self, prcxi_9320_handler, well_plate_96):
|
||||
"""测试单通道分配液体"""
|
||||
# 分配液体
|
||||
well = well_plate_96.get_item("A1")
|
||||
await prcxi_9320_handler.dispense([well], [25], [0])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "Tapping"
|
||||
assert step["DosageNum"] == 25
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mix_single_channel(self, prcxi_9320_handler, well_plate_96):
|
||||
"""测试单通道混合液体"""
|
||||
# 混合液体
|
||||
well = well_plate_96.get_item("A1")
|
||||
await prcxi_9320_handler.mix([well], mix_time=3, mix_vol=50)
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "Blending"
|
||||
assert step["BlendingTimes"] == 3
|
||||
assert step["DosageNum"] == 50
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drop_tips_to_trash(self, prcxi_9320_handler, trash_container):
|
||||
"""测试丢弃枪头到垃圾桶"""
|
||||
# 丢弃枪头
|
||||
await prcxi_9320_handler.drop_tips([trash_container], [0])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "UnLoad"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_tips(self, prcxi_9320_handler):
|
||||
"""测试丢弃枪头"""
|
||||
# 丢弃枪头
|
||||
await prcxi_9320_handler.discard_tips([0])
|
||||
|
||||
# 验证步骤已添加到待办列表
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 1
|
||||
step = prcxi_9320_handler._unilabos_backend.steps_todo_list[0]
|
||||
assert step["Function"] == "UnLoad"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liquid_transfer_workflow(self, prcxi_9320_handler, tip_rack_10ul, well_plate_96):
|
||||
"""测试完整的液体转移工作流程"""
|
||||
# 设置枪头盒和液体
|
||||
prcxi_9320_handler.set_tiprack([tip_rack_10ul])
|
||||
source_well = well_plate_96.get_item("A1")
|
||||
target_well = well_plate_96.get_item("B1")
|
||||
prcxi_9320_handler.set_liquid([source_well], ["water"], [100])
|
||||
|
||||
# 创建协议
|
||||
await prcxi_9320_handler.create_protocol(protocol_name="Test Transfer Protocol")
|
||||
|
||||
# 执行转移流程
|
||||
tip_spot = tip_rack_10ul.get_item("A1")
|
||||
await prcxi_9320_handler.pick_up_tips([tip_spot], [0])
|
||||
await prcxi_9320_handler.aspirate([source_well], [50], [0])
|
||||
await prcxi_9320_handler.dispense([target_well], [50], [0])
|
||||
await prcxi_9320_handler.discard_tips([0])
|
||||
|
||||
# 验证所有步骤都已添加
|
||||
assert len(prcxi_9320_handler._unilabos_backend.steps_todo_list) == 4
|
||||
functions = [step["Function"] for step in prcxi_9320_handler._unilabos_backend.steps_todo_list]
|
||||
assert functions == ["Load", "Imbibing", "Tapping", "UnLoad"]
|
||||
|
||||
|
||||
class TestPRCXILayoutRecommendation:
|
||||
"""测试 PRCXI 板位推荐功能"""
|
||||
|
||||
def test_9300_layout_creation(self, default_layout_9300):
|
||||
"""测试 PRCXI 9300 布局创建"""
|
||||
layout_info = default_layout_9300.get_layout()
|
||||
assert layout_info["rows"] == 2
|
||||
assert layout_info["columns"] == 3
|
||||
assert len(layout_info["layout"]) == 6
|
||||
assert layout_info["trash_slot"] == 6
|
||||
assert "waste_liquid_slot" not in layout_info
|
||||
|
||||
def test_9320_layout_creation(self, default_layout_9320):
|
||||
"""测试 PRCXI 9320 布局创建"""
|
||||
layout_info = default_layout_9320.get_layout()
|
||||
assert layout_info["rows"] == 4
|
||||
assert layout_info["columns"] == 4
|
||||
assert len(layout_info["layout"]) == 16
|
||||
assert layout_info["trash_slot"] == 16
|
||||
assert layout_info["waste_liquid_slot"] == 12
|
||||
|
||||
def test_layout_recommendation_9320(self, default_layout_9320, prcxi_materials):
|
||||
"""测试 PRCXI 9320 板位推荐功能"""
|
||||
# 添加物料信息
|
||||
default_layout_9320.add_lab_resource(prcxi_materials)
|
||||
|
||||
# 推荐布局
|
||||
needs = [
|
||||
("reagent_1", "96 细胞培养皿", 3),
|
||||
("reagent_2", "12道储液槽", 1),
|
||||
("reagent_3", "200μL Tip头", 7),
|
||||
("reagent_4", "10μL加长 Tip头", 1),
|
||||
]
|
||||
|
||||
matrix_layout, layout_list = default_layout_9320.recommend_layout(needs)
|
||||
|
||||
# 验证返回结果
|
||||
assert "MatrixId" in matrix_layout
|
||||
assert "MatrixName" in matrix_layout
|
||||
assert "MatrixCount" in matrix_layout
|
||||
assert "WorkTablets" in matrix_layout
|
||||
assert len(layout_list) == 12 # 3+1+7+1 = 12个位置
|
||||
|
||||
# 验证推荐的位置不包含预留位置
|
||||
reserved_positions = {12, 16}
|
||||
recommended_positions = [item["positions"] for item in layout_list]
|
||||
for pos in recommended_positions:
|
||||
assert pos not in reserved_positions
|
||||
|
||||
def test_layout_recommendation_insufficient_space(self, default_layout_9320, prcxi_materials):
|
||||
"""测试板位推荐空间不足的情况"""
|
||||
# 添加物料信息
|
||||
default_layout_9320.add_lab_resource(prcxi_materials)
|
||||
|
||||
# 尝试推荐超过可用空间的布局
|
||||
needs = [
|
||||
("reagent_1", "96 细胞培养皿", 15), # 需要15个位置,但只有14个可用
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError, match="需要 .* 个位置,但只有 .* 个可用位置"):
|
||||
default_layout_9320.recommend_layout(needs)
|
||||
|
||||
def test_layout_recommendation_material_not_found(self, default_layout_9320, prcxi_materials):
|
||||
"""测试板位推荐物料不存在的情况"""
|
||||
# 添加物料信息
|
||||
default_layout_9320.add_lab_resource(prcxi_materials)
|
||||
|
||||
# 尝试推荐不存在的物料
|
||||
needs = [
|
||||
("reagent_1", "不存在的物料", 1),
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError, match="Material .* not found in lab resources"):
|
||||
default_layout_9320.recommend_layout(needs)
|
||||
|
||||
|
||||
class TestPRCXIBackendOperations:
|
||||
"""测试 PRCXI 后端操作功能"""
|
||||
|
||||
def test_backend_initialization(self, prcxi_9300_handler):
|
||||
"""测试后端初始化"""
|
||||
backend = prcxi_9300_handler._unilabos_backend
|
||||
assert isinstance(backend, PRCXI9300Backend)
|
||||
assert backend._num_channels == 8
|
||||
assert backend.debug is True
|
||||
|
||||
def test_protocol_creation(self, prcxi_9300_handler):
|
||||
"""测试协议创建"""
|
||||
backend = prcxi_9300_handler._unilabos_backend
|
||||
backend.create_protocol("Test Protocol")
|
||||
assert backend.protocol_name == "Test Protocol"
|
||||
assert len(backend.steps_todo_list) == 0
|
||||
|
||||
def test_channel_validation(self):
|
||||
"""测试通道验证"""
|
||||
# 测试正确的8通道配置
|
||||
valid_channels = [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
result = PRCXI9300Backend.check_channels(valid_channels)
|
||||
assert result == valid_channels
|
||||
|
||||
# 测试错误的通道配置
|
||||
invalid_channels = [0, 1, 2, 3]
|
||||
result = PRCXI9300Backend.check_channels(invalid_channels)
|
||||
assert result == [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
|
||||
def test_matrix_info_creation(self, prcxi_9300_handler):
|
||||
"""测试矩阵信息创建"""
|
||||
backend = prcxi_9300_handler._unilabos_backend
|
||||
backend.create_protocol("Test Protocol")
|
||||
|
||||
# 模拟运行协议时的矩阵信息创建
|
||||
run_time = 1234567890
|
||||
matrix_info = MatrixInfo(
|
||||
MatrixId=f"{int(run_time)}",
|
||||
MatrixName=f"protocol_{run_time}",
|
||||
MatrixCount=len(backend.tablets_info),
|
||||
WorkTablets=backend.tablets_info,
|
||||
)
|
||||
|
||||
assert matrix_info["MatrixId"] == str(int(run_time))
|
||||
assert matrix_info["MatrixName"] == f"protocol_{run_time}"
|
||||
assert "WorkTablets" in matrix_info
|
||||
|
||||
|
||||
class TestPRCXIContainerOperations:
|
||||
"""测试 PRCXI 容器操作功能"""
|
||||
|
||||
def test_container_serialization(self, tip_rack_300ul):
|
||||
"""测试容器序列化"""
|
||||
serialized = tip_rack_300ul.serialize_state()
|
||||
assert "Material" in serialized
|
||||
assert serialized["Material"]["Name"] == "300μL Tip头"
|
||||
|
||||
def test_container_deserialization(self, tip_rack_300ul):
|
||||
"""测试容器反序列化"""
|
||||
# 序列化
|
||||
serialized = tip_rack_300ul.serialize_state()
|
||||
|
||||
# 创建新容器并反序列化
|
||||
new_tip_rack = PRCXI9300Container(
|
||||
name="new_tip_rack",
|
||||
size_x=50,
|
||||
size_y=50,
|
||||
size_z=10,
|
||||
category="tip_rack",
|
||||
ordering=collections.OrderedDict()
|
||||
)
|
||||
new_tip_rack.load_state(serialized)
|
||||
|
||||
assert new_tip_rack._unilabos_state["Material"]["Name"] == "300μL Tip头"
|
||||
|
||||
def test_trash_container_creation(self, prcxi_materials):
|
||||
"""测试垃圾桶容器创建"""
|
||||
trash = PRCXI9300Trash(name="trash", size_x=50, size_y=50, size_z=10, category="trash")
|
||||
trash.load_state({
|
||||
"Material": {
|
||||
"uuid": prcxi_materials["废弃槽"]["uuid"]
|
||||
}
|
||||
})
|
||||
|
||||
assert trash.name == "trash"
|
||||
assert trash._unilabos_state["Material"]["uuid"] == prcxi_materials["废弃槽"]["uuid"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 运行测试
|
||||
pytest.main([__file__, "-v"])
|
||||
15
tests/devices/liquid_handling/README.md
Normal file
15
tests/devices/liquid_handling/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Liquid handling 集成测试
|
||||
|
||||
`test_transfer_liquid.py` 现在会调用 PRCXI 的 RViz 仿真 backend,运行前请确保:
|
||||
|
||||
1. 已安装包含 `pylabrobot`、`rclpy` 的运行环境;
|
||||
2. 启动 ROS 依赖(`rviz` 可选,但是 `rviz_backend` 会创建 ROS 节点);
|
||||
3. 在 shell 中设置 `UNILAB_SIM_TEST=1`,否则 pytest 会自动跳过这些慢速用例:
|
||||
|
||||
```bash
|
||||
export UNILAB_SIM_TEST=1
|
||||
pytest tests/devices/liquid_handling/test_transfer_liquid.py -m slow
|
||||
```
|
||||
|
||||
如果只需验证逻辑层(不依赖仿真),可以直接运行 `tests/devices/liquid_handling/unit_test.py`,该文件使用 Fake backend,适合作为 CI 的快速测试。***
|
||||
|
||||
@@ -39,6 +39,11 @@ class FakeLiquidHandler(LiquidHandlerAbstract):
|
||||
self.current_tip = iter(make_tip_iter())
|
||||
self.calls: List[Tuple[str, Any]] = []
|
||||
|
||||
def set_tiprack(self, tip_racks):
|
||||
if not tip_racks:
|
||||
return
|
||||
super().set_tiprack(tip_racks)
|
||||
|
||||
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
|
||||
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
||||
|
||||
|
||||
608
tests/devices/liquid_handling/unit_test.py
Normal file
608
tests/devices/liquid_handling/unit_test.py
Normal file
@@ -0,0 +1,608 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
from unilabos.devices.liquid_handling.liquid_handler_abstract import LiquidHandlerAbstract
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DummyContainer:
|
||||
name: str
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"DummyContainer({self.name})"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DummyTipSpot:
|
||||
name: str
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"DummyTipSpot({self.name})"
|
||||
|
||||
|
||||
def make_tip_iter(n: int = 256) -> Iterable[List[DummyTipSpot]]:
|
||||
"""Yield lists so code can safely call `tip.extend(next(self.current_tip))`."""
|
||||
for i in range(n):
|
||||
yield [DummyTipSpot(f"tip_{i}")]
|
||||
|
||||
|
||||
class FakeLiquidHandler(LiquidHandlerAbstract):
|
||||
"""不初始化真实 backend/deck;仅用来记录 transfer_liquid 内部调用序列。"""
|
||||
|
||||
def __init__(self, channel_num: int = 8):
|
||||
# 不调用 super().__init__,避免真实硬件/后端依赖
|
||||
self.channel_num = channel_num
|
||||
self.support_touch_tip = True
|
||||
self.current_tip = iter(make_tip_iter())
|
||||
self.calls: List[Tuple[str, Any]] = []
|
||||
|
||||
def set_tiprack(self, tip_racks):
|
||||
# transfer_liquid 总会调用 set_tiprack;测试用 Dummy 枪头时 tip_racks 为空,需保留自种子的 current_tip
|
||||
if not tip_racks:
|
||||
return
|
||||
super().set_tiprack(tip_racks)
|
||||
|
||||
async def pick_up_tips(self, tip_spots, use_channels=None, offsets=None, **backend_kwargs):
|
||||
self.calls.append(("pick_up_tips", {"tips": list(tip_spots), "use_channels": use_channels}))
|
||||
|
||||
async def aspirate(
|
||||
self,
|
||||
resources: Sequence[Any],
|
||||
vols: List[float],
|
||||
use_channels: Optional[List[int]] = None,
|
||||
flow_rates: Optional[List[Optional[float]]] = None,
|
||||
offsets: Any = None,
|
||||
liquid_height: Any = None,
|
||||
blow_out_air_volume: Any = None,
|
||||
spread: str = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
self.calls.append(
|
||||
(
|
||||
"aspirate",
|
||||
{
|
||||
"resources": list(resources),
|
||||
"vols": list(vols),
|
||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
||||
"offsets": list(offsets) if offsets is not None else None,
|
||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def dispense(
|
||||
self,
|
||||
resources: Sequence[Any],
|
||||
vols: List[float],
|
||||
use_channels: Optional[List[int]] = None,
|
||||
flow_rates: Optional[List[Optional[float]]] = None,
|
||||
offsets: Any = None,
|
||||
liquid_height: Any = None,
|
||||
blow_out_air_volume: Any = None,
|
||||
spread: str = "wide",
|
||||
**backend_kwargs,
|
||||
):
|
||||
self.calls.append(
|
||||
(
|
||||
"dispense",
|
||||
{
|
||||
"resources": list(resources),
|
||||
"vols": list(vols),
|
||||
"use_channels": list(use_channels) if use_channels is not None else None,
|
||||
"flow_rates": list(flow_rates) if flow_rates is not None else None,
|
||||
"offsets": list(offsets) if offsets is not None else None,
|
||||
"liquid_height": list(liquid_height) if liquid_height is not None else None,
|
||||
"blow_out_air_volume": list(blow_out_air_volume) if blow_out_air_volume is not None else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def discard_tips(self, use_channels=None, *args, **kwargs):
|
||||
# 有的分支是 discard_tips(use_channels=[0]),有的分支是 discard_tips([0..7])(位置参数)
|
||||
self.calls.append(("discard_tips", {"use_channels": list(use_channels) if use_channels is not None else None}))
|
||||
|
||||
async def custom_delay(self, seconds=0, msg=None):
|
||||
self.calls.append(("custom_delay", {"seconds": seconds, "msg": msg}))
|
||||
|
||||
async def touch_tip(self, targets):
|
||||
# 原实现会访问 targets.get_size_x() 等;测试里只记录调用
|
||||
self.calls.append(("touch_tip", {"targets": targets}))
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_one_to_one_single_channel_basic_calls():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 2, 3],
|
||||
dis_vols=[4, 5, 6],
|
||||
mix_times=None, # 应该仍能执行(不 mix)
|
||||
)
|
||||
)
|
||||
|
||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 3
|
||||
assert [c[0] for c in lh.calls].count("aspirate") == 3
|
||||
assert [c[0] for c in lh.calls].count("dispense") == 3
|
||||
assert [c[0] for c in lh.calls].count("discard_tips") == 3
|
||||
|
||||
# 每次 aspirate/dispense 都是单孔列表
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert aspirates[0]["resources"] == [sources[0]]
|
||||
assert aspirates[0]["vols"] == [1.0]
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert dispenses[2]["resources"] == [targets[2]]
|
||||
assert dispenses[2]["vols"] == [6.0]
|
||||
|
||||
|
||||
def test_one_to_one_single_channel_before_stage_mixes_prior_to_aspirate():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(16))
|
||||
|
||||
source = DummyContainer("S0")
|
||||
target = DummyContainer("T0")
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[5],
|
||||
dis_vols=[5],
|
||||
mix_stage="before",
|
||||
mix_times=1,
|
||||
mix_vol=3,
|
||||
)
|
||||
)
|
||||
|
||||
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
|
||||
assert len(aspirate_calls) >= 2
|
||||
mix_idx, mix_payload = aspirate_calls[0]
|
||||
assert mix_payload["resources"] == [target]
|
||||
assert mix_payload["vols"] == [3]
|
||||
transfer_idx, transfer_payload = aspirate_calls[1]
|
||||
assert transfer_payload["resources"] == [source]
|
||||
assert mix_idx < transfer_idx
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_groups_by_8():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(256))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
||||
asp_vols = list(range(1, 17))
|
||||
dis_vols = list(range(101, 117))
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0, # 触发逻辑但不 mix
|
||||
)
|
||||
)
|
||||
|
||||
# 16 个任务 -> 2 组,每组 8 通道一起做
|
||||
assert [c[0] for c in lh.calls].count("pick_up_tips") == 2
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == 2
|
||||
assert len(dispenses) == 2
|
||||
|
||||
assert aspirates[0]["resources"] == sources[0:8]
|
||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols[0:8]]
|
||||
assert dispenses[1]["resources"] == targets[8:16]
|
||||
assert dispenses[1]["vols"] == [float(v) for v in dis_vols[8:16]]
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_requires_multiple_of_8_targets():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(9)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(9)]
|
||||
|
||||
with pytest.raises(ValueError, match="multiple of 8"):
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=[1] * 9,
|
||||
dis_vols=[1] * 9,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_parameter_lists_are_chunked_per_8():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(512))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(16)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(16)]
|
||||
asp_vols = [i + 1 for i in range(16)]
|
||||
dis_vols = [200 + i for i in range(16)]
|
||||
asp_flow_rates = [0.1 * (i + 1) for i in range(16)]
|
||||
dis_flow_rates = [0.2 * (i + 1) for i in range(16)]
|
||||
offsets = [f"offset_{i}" for i in range(16)]
|
||||
liquid_heights = [i * 0.5 for i in range(16)]
|
||||
blow_out_air_volume = [i + 0.05 for i in range(16)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
asp_flow_rates=asp_flow_rates,
|
||||
dis_flow_rates=dis_flow_rates,
|
||||
offsets=offsets,
|
||||
liquid_height=liquid_heights,
|
||||
blow_out_air_volume=blow_out_air_volume,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == len(dispenses) == 2
|
||||
|
||||
for batch_idx in range(2):
|
||||
start = batch_idx * 8
|
||||
end = start + 8
|
||||
asp_call = aspirates[batch_idx]
|
||||
dis_call = dispenses[batch_idx]
|
||||
assert asp_call["resources"] == sources[start:end]
|
||||
assert asp_call["flow_rates"] == asp_flow_rates[start:end]
|
||||
assert asp_call["offsets"] == offsets[start:end]
|
||||
assert asp_call["liquid_height"] == liquid_heights[start:end]
|
||||
assert asp_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
||||
assert dis_call["flow_rates"] == dis_flow_rates[start:end]
|
||||
assert dis_call["offsets"] == offsets[start:end]
|
||||
assert dis_call["liquid_height"] == liquid_heights[start:end]
|
||||
assert dis_call["blow_out_air_volume"] == blow_out_air_volume[start:end]
|
||||
|
||||
|
||||
def test_one_to_one_eight_channel_handles_32_tasks_four_batches():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(1024))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(32)]
|
||||
targets = [DummyContainer(f"T{i}") for i in range(32)]
|
||||
asp_vols = [i + 1 for i in range(32)]
|
||||
dis_vols = [300 + i for i in range(32)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
pick_calls = [name for name, _ in lh.calls if name == "pick_up_tips"]
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(pick_calls) == 4
|
||||
assert len(aspirates) == len(dispenses) == 4
|
||||
assert aspirates[0]["resources"] == sources[0:8]
|
||||
assert aspirates[-1]["resources"] == sources[24:32]
|
||||
assert dispenses[0]["resources"] == targets[0:8]
|
||||
assert dispenses[-1]["resources"] == targets[24:32]
|
||||
|
||||
|
||||
def test_one_to_many_single_channel_aspirates_total_when_asp_vol_too_small():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
source = DummyContainer("SRC")
|
||||
targets = [DummyContainer(f"T{i}") for i in range(3)]
|
||||
dis_vols = [10, 20, 30] # sum=60
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=10, # 小于 sum(dis_vols) -> 应吸 60
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert len(aspirates) == 1
|
||||
assert aspirates[0]["resources"] == [source]
|
||||
assert aspirates[0]["vols"] == [60.0]
|
||||
assert aspirates[0]["use_channels"] == [0]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [10.0, 20.0, 30.0]
|
||||
|
||||
|
||||
def test_one_to_many_eight_channel_basic():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
source = DummyContainer("SRC")
|
||||
targets = [DummyContainer(f"T{i}") for i in range(8)]
|
||||
dis_vols = [i + 1 for i in range(8)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=[source],
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=999, # one-to-many 8ch 会按 dis_vols 吸(每通道各自)
|
||||
dis_vols=dis_vols,
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert aspirates[0]["resources"] == [source] * 8
|
||||
assert aspirates[0]["vols"] == [float(v) for v in dis_vols]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert dispenses[0]["resources"] == targets
|
||||
assert dispenses[0]["vols"] == [float(v) for v in dis_vols]
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_standard_dispense_equals_asp_by_default():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [5, 6, 7]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=1, # many-to-one 允许标量;非比例模式下实际每次分液=对应 asp_vol
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in asp_vols]
|
||||
assert all(d["resources"] == [target] for d in dispenses)
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_before_stage_mixes_target_once():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
||||
target = DummyContainer("T")
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[5, 6],
|
||||
dis_vols=1,
|
||||
mix_stage="before",
|
||||
mix_times=2,
|
||||
mix_vol=4,
|
||||
)
|
||||
)
|
||||
|
||||
aspirate_calls = [(idx, payload) for idx, (name, payload) in enumerate(lh.calls) if name == "aspirate"]
|
||||
assert len(aspirate_calls) >= 1
|
||||
mix_idx, mix_payload = aspirate_calls[0]
|
||||
assert mix_payload["resources"] == [target]
|
||||
assert mix_payload["vols"] == [4]
|
||||
# 第一個 mix 之後會真正開始吸 source
|
||||
assert any(call["resources"] == [sources[0]] for _, call in aspirate_calls[1:])
|
||||
|
||||
|
||||
def test_many_to_one_single_channel_proportional_mixing_uses_dis_vols_per_source():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
lh.current_tip = iter(make_tip_iter(128))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(3)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [5, 6, 7]
|
||||
dis_vols = [1, 2, 3]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=dis_vols, # 比例模式
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert [d["vols"][0] for d in dispenses] == [float(v) for v in dis_vols]
|
||||
|
||||
|
||||
def test_many_to_one_eight_channel_basic():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(256))
|
||||
|
||||
sources = [DummyContainer(f"S{i}") for i in range(8)]
|
||||
target = DummyContainer("T")
|
||||
asp_vols = [10 + i for i in range(8)]
|
||||
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=[target],
|
||||
tip_racks=[],
|
||||
use_channels=list(range(8)),
|
||||
asp_vols=asp_vols,
|
||||
dis_vols=999, # 非比例模式下每通道分液=对应 asp_vol
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert aspirates[0]["resources"] == sources
|
||||
assert aspirates[0]["vols"] == [float(v) for v in asp_vols]
|
||||
assert dispenses[0]["resources"] == [target] * 8
|
||||
assert dispenses[0]["vols"] == [float(v) for v in asp_vols]
|
||||
|
||||
|
||||
def test_transfer_liquid_mode_detection_unsupported_shape_raises():
|
||||
lh = FakeLiquidHandler(channel_num=8)
|
||||
lh.current_tip = iter(make_tip_iter(64))
|
||||
|
||||
sources = [DummyContainer("S0"), DummyContainer("S1")]
|
||||
targets = [DummyContainer("T0"), DummyContainer("T1"), DummyContainer("T2")]
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported transfer mode"):
|
||||
run(
|
||||
lh.transfer_liquid(
|
||||
sources=sources,
|
||||
targets=targets,
|
||||
tip_racks=[],
|
||||
use_channels=[0],
|
||||
asp_vols=[1, 1],
|
||||
dis_vols=[1, 1, 1],
|
||||
mix_times=0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_mix_single_target_produces_matching_cycles():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
target = DummyContainer("T_mix")
|
||||
|
||||
run(lh.mix(targets=[target], mix_time=2, mix_vol=5))
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
dispenses = [payload for name, payload in lh.calls if name == "dispense"]
|
||||
assert len(aspirates) == len(dispenses) == 2
|
||||
assert all(call["resources"] == [target] for call in aspirates)
|
||||
assert all(call["vols"] == [5] for call in aspirates)
|
||||
assert all(call["resources"] == [target] for call in dispenses)
|
||||
assert all(call["vols"] == [5] for call in dispenses)
|
||||
|
||||
|
||||
def test_mix_multiple_targets_supports_per_target_offsets():
|
||||
lh = FakeLiquidHandler(channel_num=1)
|
||||
targets = [DummyContainer("T0"), DummyContainer("T1")]
|
||||
offsets = ["left", "right"]
|
||||
heights = [0.1, 0.2]
|
||||
rates = [0.5, 1.0]
|
||||
|
||||
run(
|
||||
lh.mix(
|
||||
targets=targets,
|
||||
mix_time=1,
|
||||
mix_vol=3,
|
||||
offsets=offsets,
|
||||
height_to_bottom=heights,
|
||||
mix_rate=rates,
|
||||
)
|
||||
)
|
||||
|
||||
aspirates = [payload for name, payload in lh.calls if name == "aspirate"]
|
||||
assert len(aspirates) == 2
|
||||
assert aspirates[0]["resources"] == [targets[0]]
|
||||
assert aspirates[0]["offsets"] == [offsets[0]]
|
||||
assert aspirates[0]["liquid_height"] == [heights[0]]
|
||||
assert aspirates[0]["flow_rates"] == [rates[0]]
|
||||
assert aspirates[1]["resources"] == [targets[1]]
|
||||
assert aspirates[1]["offsets"] == [offsets[1]]
|
||||
assert aspirates[1]["liquid_height"] == [heights[1]]
|
||||
assert aspirates[1]["flow_rates"] == [rates[1]]
|
||||
|
||||
|
||||
def test_set_tiprack_per_type_resumes_first_physical_rack():
|
||||
"""同型号多次 set_tiprack 时接续第一盒剩余孔位,而非从新盒 A1 开始。"""
|
||||
from pylabrobot.liquid_handling import LiquidHandlerChatterboxBackend
|
||||
from pylabrobot.resources import Deck, Tip, TipRack, TipSpot, create_equally_spaced
|
||||
|
||||
mk = lambda: Tip(
|
||||
has_filter=False, total_tip_length=10.0, maximal_volume=300.0, fitting_depth=2.0
|
||||
)
|
||||
|
||||
class TipTypeAlpha(TipRack):
|
||||
pass
|
||||
|
||||
class TipTypeBeta(TipRack):
|
||||
pass
|
||||
|
||||
def make_rack(cls: type, name: str) -> TipRack:
|
||||
items = create_equally_spaced(
|
||||
TipSpot,
|
||||
num_items_x=12,
|
||||
num_items_y=2,
|
||||
dx=0,
|
||||
dy=0,
|
||||
dz=0,
|
||||
item_dx=9,
|
||||
item_dy=9,
|
||||
size_x=1,
|
||||
size_y=1,
|
||||
make_tip=mk,
|
||||
)
|
||||
return cls(name, 120, 40, 10, items=items)
|
||||
|
||||
rack1 = make_rack(TipTypeAlpha, "rack_phys_1")
|
||||
rack2 = make_rack(TipTypeBeta, "rack_phys_2")
|
||||
rack3 = make_rack(TipTypeAlpha, "rack_phys_3")
|
||||
|
||||
lh = LiquidHandlerAbstract(
|
||||
LiquidHandlerChatterboxBackend(1), Deck(), channel_num=1, simulator=False
|
||||
)
|
||||
flat1 = lh._flatten_tips_from_one(rack1)
|
||||
assert len(flat1) == 24
|
||||
|
||||
lh.set_tiprack([rack1])
|
||||
for i in range(12):
|
||||
assert lh._get_next_tip() is flat1[i]
|
||||
|
||||
lh.set_tiprack([rack2])
|
||||
spot_b = lh._get_next_tip()
|
||||
assert "rack_phys_2" in spot_b.name
|
||||
|
||||
lh.set_tiprack([rack3])
|
||||
spot_resume = lh._get_next_tip()
|
||||
assert spot_resume is flat1[12], "第三次同型号应接续 rack1 第二行首孔,而非 rack3 首孔"
|
||||
assert spot_resume is not lh._flatten_tips_from_one(rack3)[0]
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
"""Entry point for `python -m unilabos`."""
|
||||
|
||||
from unilabos.app.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -58,14 +58,14 @@ class JobResultStore:
|
||||
feedback=feedback or {},
|
||||
timestamp=time.time(),
|
||||
)
|
||||
logger.trace(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
||||
logger.debug(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
||||
|
||||
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
|
||||
"""获取并删除任务结果"""
|
||||
with self._results_lock:
|
||||
result = self._results.pop(job_id, None)
|
||||
if result:
|
||||
logger.trace(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
||||
logger.debug(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
||||
return result
|
||||
|
||||
def get_result(self, job_id: str) -> Optional[JobResult]:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,7 @@ class Base(ABC):
|
||||
self._type = typ
|
||||
self._data_type = data_type
|
||||
self._node: Optional[Node] = None
|
||||
|
||||
|
||||
def _get_node(self) -> Node:
|
||||
if self._node is None:
|
||||
try:
|
||||
@@ -66,7 +66,7 @@ class Base(ABC):
|
||||
# 直接以字符串形式处理
|
||||
if isinstance(nid, str):
|
||||
nid = nid.strip()
|
||||
|
||||
|
||||
# 处理包含类名的格式,如 'StringNodeId(ns=4;s=...)' 或 'NumericNodeId(ns=2;i=...)'
|
||||
# 提取括号内的内容
|
||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
||||
@@ -116,16 +116,16 @@ class Base(ABC):
|
||||
def read(self) -> Tuple[Any, bool]:
|
||||
"""读取节点值,返回(值, 是否出错)"""
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def write(self, value: Any) -> bool:
|
||||
"""写入节点值,返回是否出错"""
|
||||
pass
|
||||
|
||||
|
||||
@property
|
||||
def type(self) -> NodeType:
|
||||
return self._type
|
||||
|
||||
|
||||
@property
|
||||
def node_id(self) -> str:
|
||||
return self._node_id
|
||||
@@ -210,15 +210,15 @@ class Method(Base):
|
||||
super().__init__(client, name, node_id, NodeType.METHOD, data_type)
|
||||
self._parent_node_id = parent_node_id
|
||||
self._parent_node = None
|
||||
|
||||
|
||||
def _get_parent_node(self) -> Node:
|
||||
if self._parent_node is None:
|
||||
try:
|
||||
# 处理父节点ID,使用与_get_node相同的解析逻辑
|
||||
import re
|
||||
|
||||
|
||||
nid = self._parent_node_id
|
||||
|
||||
|
||||
# 如果已经是 NodeId 对象,直接使用
|
||||
try:
|
||||
from opcua.ua import NodeId as UaNodeId
|
||||
@@ -227,16 +227,16 @@ class Method(Base):
|
||||
return self._parent_node
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# 字符串处理
|
||||
if isinstance(nid, str):
|
||||
nid = nid.strip()
|
||||
|
||||
|
||||
# 处理包含类名的格式
|
||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
||||
if match_wrapped:
|
||||
nid = match_wrapped.group(2).strip()
|
||||
|
||||
|
||||
# 常见短格式
|
||||
if re.match(r'^ns=\d+;[is]=', nid):
|
||||
self._parent_node = self._client.get_node(nid)
|
||||
@@ -271,7 +271,7 @@ class Method(Base):
|
||||
def write(self, value: Any) -> bool:
|
||||
"""方法节点不支持写入操作"""
|
||||
return True
|
||||
|
||||
|
||||
def call(self, *args) -> Tuple[Any, bool]:
|
||||
"""调用方法,返回(返回值, 是否出错)"""
|
||||
try:
|
||||
@@ -285,7 +285,7 @@ class Method(Base):
|
||||
class Object(Base):
|
||||
def __init__(self, client: Client, name: str, node_id: str):
|
||||
super().__init__(client, name, node_id, NodeType.OBJECT, None)
|
||||
|
||||
|
||||
def read(self) -> Tuple[Any, bool]:
|
||||
"""对象节点不支持直接读取操作"""
|
||||
return None, True
|
||||
@@ -293,7 +293,7 @@ class Object(Base):
|
||||
def write(self, value: Any) -> bool:
|
||||
"""对象节点不支持直接写入操作"""
|
||||
return True
|
||||
|
||||
|
||||
def get_children(self) -> Tuple[List[Node], bool]:
|
||||
"""获取子节点列表,返回(子节点列表, 是否出错)"""
|
||||
try:
|
||||
@@ -301,4 +301,4 @@ class Object(Base):
|
||||
return children, False
|
||||
except Exception as e:
|
||||
print(f"获取对象 {self._name} 的子节点失败: {e}")
|
||||
return [], True
|
||||
return [], True
|
||||
@@ -201,17 +201,42 @@ class ResourceVisualization:
|
||||
self.moveit_controllers_yaml['moveit_simple_controller_manager'][f"{name}_{controller_name}"] = moveit_dict['moveit_simple_controller_manager'][controller_name]
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _ensure_ros2_env() -> dict:
|
||||
"""确保 ROS2 环境变量正确设置,返回可用于子进程的 env dict"""
|
||||
import sys
|
||||
env = dict(os.environ)
|
||||
conda_prefix = os.path.dirname(os.path.dirname(sys.executable))
|
||||
|
||||
if "AMENT_PREFIX_PATH" not in env or not env["AMENT_PREFIX_PATH"].strip():
|
||||
candidate = os.pathsep.join([conda_prefix, os.path.join(conda_prefix, "Library")])
|
||||
env["AMENT_PREFIX_PATH"] = candidate
|
||||
os.environ["AMENT_PREFIX_PATH"] = candidate
|
||||
|
||||
extra_bin_dirs = [
|
||||
os.path.join(conda_prefix, "Library", "bin"),
|
||||
os.path.join(conda_prefix, "Library", "lib"),
|
||||
os.path.join(conda_prefix, "Scripts"),
|
||||
conda_prefix,
|
||||
]
|
||||
current_path = env.get("PATH", "")
|
||||
for d in extra_bin_dirs:
|
||||
if d not in current_path:
|
||||
current_path = d + os.pathsep + current_path
|
||||
env["PATH"] = current_path
|
||||
os.environ["PATH"] = current_path
|
||||
|
||||
return env
|
||||
|
||||
def create_launch_description(self) -> LaunchDescription:
|
||||
"""
|
||||
创建launch描述,包含robot_state_publisher和move_group节点
|
||||
|
||||
Args:
|
||||
urdf_str: URDF文本
|
||||
|
||||
Returns:
|
||||
LaunchDescription: launch描述对象
|
||||
"""
|
||||
# 检查ROS 2环境变量
|
||||
launch_env = self._ensure_ros2_env()
|
||||
|
||||
if "AMENT_PREFIX_PATH" not in os.environ:
|
||||
raise OSError(
|
||||
"ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n"
|
||||
@@ -290,7 +315,7 @@ class ResourceVisualization:
|
||||
{"robot_description": robot_description},
|
||||
ros2_controllers,
|
||||
],
|
||||
env=dict(os.environ)
|
||||
env=launch_env,
|
||||
)
|
||||
)
|
||||
for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']:
|
||||
@@ -300,7 +325,7 @@ class ResourceVisualization:
|
||||
executable="spawner",
|
||||
arguments=[f"{controller}", "--controller-manager", f"controller_manager"],
|
||||
output="screen",
|
||||
env=dict(os.environ)
|
||||
env=launch_env,
|
||||
)
|
||||
)
|
||||
controllers.append(
|
||||
@@ -309,7 +334,7 @@ class ResourceVisualization:
|
||||
executable="spawner",
|
||||
arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"],
|
||||
output="screen",
|
||||
env=dict(os.environ)
|
||||
env=launch_env,
|
||||
)
|
||||
)
|
||||
for i in controllers:
|
||||
@@ -317,7 +342,6 @@ class ResourceVisualization:
|
||||
else:
|
||||
ros2_controllers = None
|
||||
|
||||
# 创建robot_state_publisher节点
|
||||
robot_state_publisher = nd(
|
||||
package='robot_state_publisher',
|
||||
executable='robot_state_publisher',
|
||||
@@ -327,9 +351,8 @@ class ResourceVisualization:
|
||||
'robot_description': robot_description,
|
||||
'use_sim_time': False
|
||||
},
|
||||
# kinematics_dict
|
||||
],
|
||||
env=dict(os.environ)
|
||||
env=launch_env,
|
||||
)
|
||||
|
||||
|
||||
@@ -361,7 +384,7 @@ class ResourceVisualization:
|
||||
executable='move_group',
|
||||
output='screen',
|
||||
parameters=moveit_params,
|
||||
env=dict(os.environ)
|
||||
env=launch_env,
|
||||
)
|
||||
|
||||
|
||||
@@ -379,13 +402,11 @@ class ResourceVisualization:
|
||||
arguments=['-d', f"{str(self.mesh_path)}/view_robot.rviz"],
|
||||
output='screen',
|
||||
parameters=[
|
||||
{'robot_description_kinematics': kinematics_dict,
|
||||
},
|
||||
{'robot_description_kinematics': kinematics_dict},
|
||||
robot_description_planning,
|
||||
planning_pipelines,
|
||||
|
||||
],
|
||||
env=dict(os.environ)
|
||||
env=launch_env,
|
||||
)
|
||||
self.launch_description.add_action(rviz_node)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
150
unilabos/devices/liquid_handling/prcxi/prcxi_modules.py
Normal file
150
unilabos/devices/liquid_handling/prcxi/prcxi_modules.py
Normal file
@@ -0,0 +1,150 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .prcxi import PRCXI9300ModuleSite
|
||||
|
||||
|
||||
class PRCXI9300FunctionalModule(PRCXI9300ModuleSite):
|
||||
"""
|
||||
PRCXI 9300 功能模块基类(加热/冷却/震荡/加热震荡/磁吸等)。
|
||||
|
||||
设计目标:
|
||||
- 作为一个可以在工作台上拖拽摆放的实体资源(继承自 PRCXI9300ModuleSite -> ItemizedCarrier)。
|
||||
- 顶面存在一个站点(site),可吸附标准板类资源(plate / tip_rack / tube_rack 等)。
|
||||
- 支持注入 `material_info` (UUID 等),并且在 serialize_state 时做安全过滤。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
module_type: Optional[str] = None,
|
||||
category: str = "module",
|
||||
model: Optional[str] = None,
|
||||
material_info: Optional[Dict[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
material_info=material_info,
|
||||
model=model,
|
||||
category=category,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# 记录模块类型(加热 / 冷却 / 震荡 / 加热震荡 / 磁吸)
|
||||
self.module_type = module_type or "generic"
|
||||
|
||||
# 与 PRCXI9300PlateAdapter 一致,使用 _unilabos_state 保存扩展信息
|
||||
if not hasattr(self, "_unilabos_state") or self._unilabos_state is None:
|
||||
self._unilabos_state = {}
|
||||
|
||||
# super().__init__ 已经在有 material_info 时写入 "Material",这里仅确保存在
|
||||
if material_info is not None and "Material" not in self._unilabos_state:
|
||||
self._unilabos_state["Material"] = material_info
|
||||
|
||||
# 额外标记 category 和模块类型,便于前端或上层逻辑区分
|
||||
self._unilabos_state.setdefault("category", category)
|
||||
self._unilabos_state["module_type"] = module_type
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 具体功能模块定义
|
||||
# 这里的尺寸和 material_info 目前为占位参数,后续可根据实际测量/JSON 配置进行更新。
|
||||
# 顶面站点尺寸与模块外形一致,保证可以吸附标准 96 板/储液槽等。
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def PRCXI_Heating_Module(name: str) -> PRCXI9300FunctionalModule:
|
||||
"""加热模块(顶面可吸附标准板)。"""
|
||||
return PRCXI9300FunctionalModule(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=40.0,
|
||||
module_type="heating",
|
||||
model="PRCXI_Heating_Module",
|
||||
material_info={
|
||||
"uuid": "TODO-HEATING-MODULE-UUID",
|
||||
"Code": "HEAT-MOD",
|
||||
"Name": "PRCXI 加热模块",
|
||||
"SupplyType": 3,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def PRCXI_MetalCooling_Module(name: str) -> PRCXI9300FunctionalModule:
|
||||
"""金属冷却模块(顶面可吸附标准板)。"""
|
||||
return PRCXI9300FunctionalModule(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=40.0,
|
||||
module_type="metal_cooling",
|
||||
model="PRCXI_MetalCooling_Module",
|
||||
material_info={
|
||||
"uuid": "TODO-METAL-COOLING-MODULE-UUID",
|
||||
"Code": "METAL-COOL-MOD",
|
||||
"Name": "PRCXI 金属冷却模块",
|
||||
"SupplyType": 3,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def PRCXI_Shaking_Module(name: str) -> PRCXI9300FunctionalModule:
|
||||
"""震荡模块(顶面可吸附标准板)。"""
|
||||
return PRCXI9300FunctionalModule(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=50.0,
|
||||
module_type="shaking",
|
||||
model="PRCXI_Shaking_Module",
|
||||
material_info={
|
||||
"uuid": "TODO-SHAKING-MODULE-UUID",
|
||||
"Code": "SHAKE-MOD",
|
||||
"Name": "PRCXI 震荡模块",
|
||||
"SupplyType": 3,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def PRCXI_Heating_Shaking_Module(name: str) -> PRCXI9300FunctionalModule:
|
||||
"""加热震荡模块(顶面可吸附标准板)。"""
|
||||
return PRCXI9300FunctionalModule(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=55.0,
|
||||
module_type="heating_shaking",
|
||||
model="PRCXI_Heating_Shaking_Module",
|
||||
material_info={
|
||||
"uuid": "TODO-HEATING-SHAKING-MODULE-UUID",
|
||||
"Code": "HEAT-SHAKE-MOD",
|
||||
"Name": "PRCXI 加热震荡模块",
|
||||
"SupplyType": 3,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def PRCXI_Magnetic_Module(name: str) -> PRCXI9300FunctionalModule:
|
||||
"""磁吸模块(顶面可吸附标准板)。"""
|
||||
return PRCXI9300FunctionalModule(
|
||||
name=name,
|
||||
size_x=127.76,
|
||||
size_y=85.48,
|
||||
size_z=30.0,
|
||||
module_type="magnetic",
|
||||
model="PRCXI_Magnetic_Module",
|
||||
material_info={
|
||||
"uuid": "TODO-MAGNETIC-MODULE-UUID",
|
||||
"Code": "MAG-MOD",
|
||||
"Name": "PRCXI 磁吸模块",
|
||||
"SupplyType": 3,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -59,6 +59,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
|
||||
self.total_height = total_height
|
||||
self.joint_config = kwargs.get("joint_config", None)
|
||||
self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher")
|
||||
self.simulate_rviz = kwargs.get("simulate_rviz", False)
|
||||
if not rclpy.ok():
|
||||
rclpy.init()
|
||||
self.joint_state_publisher = None
|
||||
@@ -69,7 +70,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
|
||||
self.joint_state_publisher = LiquidHandlerJointPublisher(
|
||||
joint_config=self.joint_config,
|
||||
lh_device_id=self.lh_device_id,
|
||||
simulate_rviz=True)
|
||||
simulate_rviz=self.simulate_rviz)
|
||||
|
||||
# 启动ROS executor
|
||||
self.executor = rclpy.executors.MultiThreadedExecutor()
|
||||
|
||||
@@ -42,6 +42,7 @@ class LiquidHandlerJointPublisher(Node):
|
||||
while self.resource_action is None:
|
||||
self.resource_action = self.check_tf_update_actions()
|
||||
time.sleep(1)
|
||||
self.get_logger().info(f'Waiting for TfUpdate server: {self.resource_action}')
|
||||
|
||||
self.resource_action_client = ActionClient(self, SendCmd, self.resource_action)
|
||||
while not self.resource_action_client.wait_for_server(timeout_sec=1.0):
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 工作站抽象基类物料系统架构说明
|
||||
|
||||
## 设计理念
|
||||
|
||||
基于用户需求"请你帮我系统思考一下,工作站抽象基类的物料系统基类该如何构建",我们最终确定了一个**PyLabRobot Deck为中心**的简化架构。
|
||||
|
||||
### 核心原则
|
||||
|
||||
1. **PyLabRobot为物料管理核心**:使用PyLabRobot的Deck系统作为物料管理的基础,利用其成熟的Resource体系
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
# Bioyond Cell 工作站 - 多订单返回示例
|
||||
|
||||
本文档说明了 `create_orders` 函数如何收集并返回所有订单的完成报文。
|
||||
|
||||
## 问题描述
|
||||
|
||||
之前的实现只会等待并返回第一个订单的完成报文,如果有多个订单(例如从 Excel 解析出 3 个订单),只能得到第一个订单的推送信息。
|
||||
|
||||
## 解决方案
|
||||
|
||||
修改后的 `create_orders` 函数现在会:
|
||||
|
||||
1. **提取所有 orderCode**:从 LIMS 接口返回的 `data` 列表中提取所有订单编号
|
||||
2. **逐个等待完成**:遍历所有 orderCode,调用 `wait_for_order_finish` 等待每个订单完成
|
||||
3. **收集所有报文**:将每个订单的完成报文存入 `all_reports` 列表
|
||||
4. **统一返回**:返回包含所有订单报文的 JSON 格式数据
|
||||
|
||||
## 返回格式
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "all_completed",
|
||||
"total_orders": 3,
|
||||
"reports": [
|
||||
{
|
||||
"token": "",
|
||||
"request_time": "2025-12-24T15:32:09.2148671+08:00",
|
||||
"data": {
|
||||
"orderId": "3a1e614d-a082-c44a-60be-68647a35e6f1",
|
||||
"orderCode": "BSO2025122400024",
|
||||
"orderName": "DP20251224001",
|
||||
"status": "30",
|
||||
"workflowStatus": "completed",
|
||||
"usedMaterials": [...]
|
||||
}
|
||||
},
|
||||
{
|
||||
"token": "",
|
||||
"request_time": "2025-12-24T15:32:09.9999039+08:00",
|
||||
"data": {
|
||||
"orderId": "3a1e614d-a0a2-f7a9-9360-610021c9479d",
|
||||
"orderCode": "BSO2025122400025",
|
||||
"orderName": "DP20251224002",
|
||||
"status": "30",
|
||||
"workflowStatus": "completed",
|
||||
"usedMaterials": [...]
|
||||
}
|
||||
},
|
||||
{
|
||||
"token": "",
|
||||
"request_time": "2025-12-24T15:34:00.4139986+08:00",
|
||||
"data": {
|
||||
"orderId": "3a1e614d-a0cd-81ca-9f7f-2f4e93af01cd",
|
||||
"orderCode": "BSO2025122400026",
|
||||
"orderName": "DP20251224003",
|
||||
"status": "30",
|
||||
"workflowStatus": "completed",
|
||||
"usedMaterials": [...]
|
||||
}
|
||||
}
|
||||
],
|
||||
"original_response": {...}
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
```python
|
||||
# 调用 create_orders
|
||||
result = workstation.create_orders("20251224.xlsx")
|
||||
|
||||
# 访问返回数据
|
||||
print(f"总订单数: {result['total_orders']}")
|
||||
print(f"状态: {result['status']}")
|
||||
|
||||
# 遍历所有订单的报文
|
||||
for i, report in enumerate(result['reports'], 1):
|
||||
order_data = report.get('data', {})
|
||||
print(f"\n订单 {i}:")
|
||||
print(f" orderCode: {order_data.get('orderCode')}")
|
||||
print(f" orderName: {order_data.get('orderName')}")
|
||||
print(f" status: {order_data.get('status')}")
|
||||
print(f" 使用物料数: {len(order_data.get('usedMaterials', []))}")
|
||||
```
|
||||
|
||||
## 控制台输出示例
|
||||
|
||||
```
|
||||
[create_orders] 即将提交订单数量: 3
|
||||
[create_orders] 接口返回: {...}
|
||||
[create_orders] 等待 3 个订单完成: ['BSO2025122400024', 'BSO2025122400025', 'BSO2025122400026']
|
||||
[create_orders] 正在等待第 1/3 个订单: BSO2025122400024
|
||||
[create_orders] ✓ 订单 BSO2025122400024 完成
|
||||
[create_orders] 正在等待第 2/3 个订单: BSO2025122400025
|
||||
[create_orders] ✓ 订单 BSO2025122400025 完成
|
||||
[create_orders] 正在等待第 3/3 个订单: BSO2025122400026
|
||||
[create_orders] ✓ 订单 BSO2025122400026 完成
|
||||
[create_orders] 所有订单已完成,共收集 3 个报文
|
||||
实验记录本========================create_orders========================
|
||||
返回报文数量: 3
|
||||
报文 1: orderCode=BSO2025122400024, status=30
|
||||
报文 2: orderCode=BSO2025122400025, status=30
|
||||
报文 3: orderCode=BSO2025122400026, status=30
|
||||
========================
|
||||
```
|
||||
|
||||
## 关键改进
|
||||
|
||||
1. ✅ **等待所有订单**:不再只等待第一个订单,而是遍历所有 orderCode
|
||||
2. ✅ **收集完整报文**:每个订单的完整推送报文都被保存在 `reports` 数组中
|
||||
3. ✅ **详细日志**:清晰显示正在等待哪个订单,以及完成情况
|
||||
4. ✅ **错误处理**:即使某个订单失败,也会记录其状态信息
|
||||
5. ✅ **统一格式**:返回的 JSON 格式便于后续处理和分析
|
||||
Binary file not shown.
@@ -1,204 +0,0 @@
|
||||
# BioyondCellWorkstation JSON 配置迁移经验总结
|
||||
|
||||
**日期**: 2026-01-13
|
||||
**目的**: 从 `config.py` 迁移到 JSON 配置文件
|
||||
|
||||
---
|
||||
|
||||
## 问题背景
|
||||
|
||||
原系统通过 `config.py` 管理配置,导致:
|
||||
1. HTTP 服务重复启动(父类 `BioyondWorkstation` 和子类都启动)
|
||||
2. 配置分散在代码中,不便于管理
|
||||
3. 无法通过 JSON 统一配置所有参数
|
||||
|
||||
---
|
||||
|
||||
## 解决方案:嵌套配置结构
|
||||
|
||||
### JSON 结构设计
|
||||
|
||||
**正确示例** (嵌套在 `config` 中):
|
||||
```json
|
||||
{
|
||||
"nodes": [{
|
||||
"id": "bioyond_cell_workstation",
|
||||
"config": {
|
||||
"deck": {...},
|
||||
"protocol_type": [],
|
||||
"bioyond_config": {
|
||||
"api_host": "http://172.16.11.219:44388",
|
||||
"api_key": "8A819E5C",
|
||||
"timeout": 30,
|
||||
"HTTP_host": "172.16.11.206",
|
||||
"HTTP_port": 8080,
|
||||
"debug_mode": false,
|
||||
"material_type_mappings": {...},
|
||||
"warehouse_mapping": {...},
|
||||
"solid_liquid_mappings": {...}
|
||||
}
|
||||
},
|
||||
"data": {}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- ✅ `bioyond_config` 放在 `config` 中(会传递到 `__init__`)
|
||||
- ❌ **不要**放在 `data` 中(`data` 是运行时状态,不会传递)
|
||||
|
||||
---
|
||||
|
||||
## Python 代码适配
|
||||
|
||||
### 1. 修改 `BioyondCellWorkstation.__init__` 签名
|
||||
|
||||
**文件**: `bioyond_cell_workstation.py`
|
||||
|
||||
```python
|
||||
def __init__(self, bioyond_config: dict = None, deck=None, protocol_type=None, **kwargs):
|
||||
"""
|
||||
Args:
|
||||
bioyond_config: 从 JSON 加载的配置字典
|
||||
deck: Deck 配置
|
||||
protocol_type: 协议类型
|
||||
"""
|
||||
# 验证配置
|
||||
if bioyond_config is None:
|
||||
raise ValueError("需要 bioyond_config 参数")
|
||||
|
||||
# 保存配置
|
||||
self.bioyond_config = bioyond_config
|
||||
|
||||
# 设置 HTTP 服务去重标志
|
||||
self.bioyond_config["_disable_auto_http_service"] = True
|
||||
|
||||
# 调用父类
|
||||
super().__init__(bioyond_config=self.bioyond_config, deck=deck, **kwargs)
|
||||
```
|
||||
|
||||
### 2. 替换全局变量引用
|
||||
|
||||
**修改前**(使用全局变量):
|
||||
```python
|
||||
from config import MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING
|
||||
|
||||
def create_sample(self, board_type, ...):
|
||||
carrier_type_id = MATERIAL_TYPE_MAPPINGS[board_type][1]
|
||||
location_id = WAREHOUSE_MAPPING[warehouse_name]["site_uuids"][location_code]
|
||||
```
|
||||
|
||||
**修改后**(从配置读取):
|
||||
```python
|
||||
def create_sample(self, board_type, ...):
|
||||
carrier_type_id = self.bioyond_config['material_type_mappings'][board_type][1]
|
||||
location_id = self.bioyond_config['warehouse_mapping'][warehouse_name]["site_uuids"][location_code]
|
||||
```
|
||||
|
||||
### 3. 修复父类配置访问
|
||||
|
||||
在 `station.py` 中安全访问配置默认值:
|
||||
|
||||
```python
|
||||
# 修改前(会 KeyError)
|
||||
self._http_service_config = {
|
||||
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"])
|
||||
}
|
||||
|
||||
# 修改后(安全访问)
|
||||
self._http_service_config = {
|
||||
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG.get("http_service_host", ""))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见陷阱
|
||||
|
||||
### ❌ 错误1:将配置放在 `data` 字段
|
||||
```json
|
||||
"config": {"deck": {...}},
|
||||
"data": {"bioyond_config": {...}} // ❌ 不会传递到 __init__
|
||||
```
|
||||
|
||||
### ❌ 错误2:扁平化配置(已废弃方案)
|
||||
虽然扁平化也能工作,但不推荐:
|
||||
```json
|
||||
"config": {
|
||||
"deck": {...},
|
||||
"api_host": "...", // ❌ 不够清晰
|
||||
"api_key": "...",
|
||||
"HTTP_host": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ 错误3:忘记替换全局变量引用
|
||||
代码中直接使用 `MATERIAL_TYPE_MAPPINGS` 等全局变量会导致 `NameError`。
|
||||
|
||||
---
|
||||
|
||||
## 云端同步注意事项
|
||||
|
||||
使用 `--upload_registry` 时,云端配置可能覆盖本地配置:
|
||||
- 首次上传时确保 JSON 完整
|
||||
- 或使用新的 `ak/sk` 避免旧配置干扰
|
||||
- 调试时可暂时移除 `--upload_registry` 参数
|
||||
|
||||
---
|
||||
|
||||
## 验证清单
|
||||
|
||||
启动成功后应看到:
|
||||
```
|
||||
✅ 从 JSON 配置加载 bioyond_config 成功
|
||||
API Host: http://...
|
||||
HTTP Service: ...
|
||||
✅ BioyondCellWorkstation 初始化完成
|
||||
Loaded ResourceTreeSet with ... nodes
|
||||
```
|
||||
|
||||
运行时不应出现:
|
||||
- ❌ `NameError: name 'MATERIAL_TYPE_MAPPINGS' is not defined`
|
||||
- ❌ `KeyError: 'http_service_host'`
|
||||
- ❌ `bioyond_config 缺少必需参数`
|
||||
|
||||
---
|
||||
|
||||
## 调试经验
|
||||
|
||||
1. **添加调试日志**查看参数传递链路:
|
||||
- `graphio.py`: JSON 加载后的 config 内容
|
||||
- `initialize_device.py`: `device_config.res_content.config` 的键
|
||||
- `bioyond_cell_workstation.py`: `__init__` 接收到的参数
|
||||
|
||||
2. **config vs data 区别**:
|
||||
- `config`: 初始化参数,传递给 `__init__`
|
||||
- `data`: 运行时状态,不传递给 `__init__`
|
||||
|
||||
3. **参数名必须匹配**:
|
||||
- JSON 中的键名必须与 `__init__` 参数名完全一致
|
||||
|
||||
4. **调试代码清理**:完成后记得删除调试日志(🔍 DEBUG 标记)
|
||||
|
||||
---
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `yibin_electrolyte_config.json` | 创建嵌套 `config.bioyond_config` 结构 |
|
||||
| `bioyond_cell_workstation.py` | 修改 `__init__` 接收 `bioyond_config`,替换所有全局变量引用 |
|
||||
| `station.py` | 安全访问 `HTTP_SERVICE_CONFIG` 默认值 |
|
||||
|
||||
---
|
||||
|
||||
## 参考代码位置
|
||||
|
||||
- JSON 配置示例: `yibin_electrolyte_config.json` L12-L353
|
||||
- `__init__` 实现: `bioyond_cell_workstation.py` L39-L94
|
||||
- 全局变量替换示例: `bioyond_cell_workstation.py` L2005, L1863, L1966
|
||||
- HTTP 服务配置: `station.py` L629-L634
|
||||
|
||||
---
|
||||
|
||||
**总结**: 使用嵌套结构将所有配置放在 `config.bioyond_config` 中,修改 `__init__` 直接接收该参数,并替换所有全局变量引用为 `self.bioyond_config` 访问。
|
||||
@@ -1,312 +0,0 @@
|
||||
# BioyondCell 配置迁移修改总结
|
||||
|
||||
**日期**: 2026-01-13
|
||||
**目标**: 从 `config.py` 完全迁移到 JSON 配置,消除所有全局变量依赖
|
||||
|
||||
---
|
||||
|
||||
## 📋 修改概览
|
||||
|
||||
本次修改完成了 BioyondCell 模块从 Python 配置文件到 JSON 配置的完整迁移,并清理了所有对 `config.py` 全局变量的依赖。
|
||||
|
||||
### 核心成果
|
||||
|
||||
- ✅ 完全移除对 `config.py` 的导入依赖
|
||||
- ✅ 使用嵌套 JSON 结构 `config.bioyond_config`
|
||||
- ✅ 修复 7 处 `bioyond_cell_workstation.py` 中的全局变量引用
|
||||
- ✅ 修复 3 处其他文件中的全局变量引用
|
||||
- ✅ HTTP 服务去重机制完善
|
||||
- ✅ 系统成功启动并正常运行
|
||||
|
||||
---
|
||||
|
||||
## 🔧 修改文件清单
|
||||
|
||||
### 1. JSON 配置文件
|
||||
|
||||
**文件**: `yibin_electrolyte_config.json`
|
||||
|
||||
**修改**:
|
||||
- 采用嵌套结构将所有配置放在 `config.bioyond_config` 中
|
||||
- 包含:`api_host`, `api_key`, `HTTP_host`, `HTTP_port`, `material_type_mappings`, `warehouse_mapping`, `solid_liquid_mappings` 等
|
||||
|
||||
**示例结构**:
|
||||
```json
|
||||
{
|
||||
"nodes": [{
|
||||
"id": "bioyond_cell_workstation",
|
||||
"config": {
|
||||
"deck": {...},
|
||||
"protocol_type": [],
|
||||
"bioyond_config": {
|
||||
"api_host": "http://172.16.11.219:44388",
|
||||
"api_key": "8A819E5C",
|
||||
"HTTP_host": "172.16.11.206",
|
||||
"HTTP_port": 8080,
|
||||
"material_type_mappings": {...},
|
||||
"warehouse_mapping": {...},
|
||||
"solid_liquid_mappings": {...}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. bioyond_cell_workstation.py
|
||||
|
||||
**位置**: `unilabos/devices/workstation/bioyond_studio/bioyond_cell/bioyond_cell_workstation.py`
|
||||
|
||||
#### 修改 A: `__init__` 方法签名 (L39-99)
|
||||
|
||||
**修改前**:
|
||||
```python
|
||||
def __init__(self, deck=None, protocol_type=None, **kwargs):
|
||||
# 从 kwargs 收集配置字段
|
||||
self.bioyond_config = {}
|
||||
for field in bioyond_field_names:
|
||||
if field in kwargs:
|
||||
self.bioyond_config[field] = kwargs.pop(field)
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
def __init__(self, bioyond_config: dict = None, deck=None, protocol_type=None, **kwargs):
|
||||
"""直接接收 bioyond_config 参数"""
|
||||
if bioyond_config is None:
|
||||
raise ValueError("需要 bioyond_config 参数")
|
||||
|
||||
self.bioyond_config = bioyond_config
|
||||
|
||||
# 设置 HTTP 服务去重标志
|
||||
self.bioyond_config["_disable_auto_http_service"] = True
|
||||
|
||||
super().__init__(bioyond_config=self.bioyond_config, deck=deck, **kwargs)
|
||||
```
|
||||
|
||||
#### 修改 B: 替换全局变量引用 (7 处)
|
||||
|
||||
| 位置 | 原代码 | 修改后 |
|
||||
|------|--------|--------|
|
||||
| L2005 | `MATERIAL_TYPE_MAPPINGS[board_type][1]` | `self.bioyond_config['material_type_mappings'][board_type][1]` |
|
||||
| L2006 | `MATERIAL_TYPE_MAPPINGS[bottle_type][1]` | `self.bioyond_config['material_type_mappings'][bottle_type][1]` |
|
||||
| L2009 | `WAREHOUSE_MAPPING` | `self.bioyond_config['warehouse_mapping']` |
|
||||
| L2013 | `WAREHOUSE_MAPPING[warehouse_name]` | `self.bioyond_config['warehouse_mapping'][warehouse_name]` |
|
||||
| L2017 | `WAREHOUSE_MAPPING[warehouse_name]["site_uuids"]` | `self.bioyond_config['warehouse_mapping'][warehouse_name]["site_uuids"]` |
|
||||
| L1863 | `SOLID_LIQUID_MAPPINGS.get(material_name)` | `self.bioyond_config.get('solid_liquid_mappings', {}).get(material_name)` |
|
||||
| L1966, L1976 | `MATERIAL_TYPE_MAPPINGS.items()` | `self.bioyond_config['material_type_mappings'].items()` |
|
||||
|
||||
---
|
||||
|
||||
### 3. station.py
|
||||
|
||||
**位置**: `unilabos/devices/workstation/bioyond_studio/station.py`
|
||||
|
||||
#### 修改 A: 删除 config 导入 (L26-28)
|
||||
|
||||
**修改前**:
|
||||
```python
|
||||
from unilabos.devices.workstation.bioyond_studio.config import (
|
||||
API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, HTTP_SERVICE_CONFIG
|
||||
)
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
# 已删除此导入
|
||||
```
|
||||
|
||||
#### 修改 B: `_create_communication_module` 方法 (L691-702)
|
||||
|
||||
**修改前**:
|
||||
```python
|
||||
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
default_config = {
|
||||
**API_CONFIG,
|
||||
"workflow_mappings": WORKFLOW_MAPPINGS,
|
||||
"material_type_mappings": MATERIAL_TYPE_MAPPINGS,
|
||||
"warehouse_mapping": WAREHOUSE_MAPPING
|
||||
}
|
||||
if config:
|
||||
self.bioyond_config = {**default_config, **config}
|
||||
else:
|
||||
self.bioyond_config = default_config
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""创建Bioyond通信模块"""
|
||||
# 使用传入的 config 参数(来自 bioyond_config)
|
||||
# 不再依赖全局变量 API_CONFIG 等
|
||||
if config:
|
||||
self.bioyond_config = config
|
||||
else:
|
||||
# 如果没有传入配置,创建空配置(用于测试或兼容性)
|
||||
self.bioyond_config = {}
|
||||
|
||||
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
||||
```
|
||||
|
||||
#### 修改 C: HTTP 服务配置 (L627-632)
|
||||
|
||||
**修改前**:
|
||||
```python
|
||||
self._http_service_config = {
|
||||
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG.get("http_service_host", "")),
|
||||
"port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG.get("http_service_port", 0))
|
||||
}
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
self._http_service_config = {
|
||||
"host": bioyond_config.get("http_service_host", bioyond_config.get("HTTP_host", "")),
|
||||
"port": bioyond_config.get("http_service_port", bioyond_config.get("HTTP_port", 0))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. bioyond_rpc.py
|
||||
|
||||
**位置**: `unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py`
|
||||
|
||||
#### 修改 A: 删除 config 导入 (L12)
|
||||
|
||||
**修改前**:
|
||||
```python
|
||||
from unilabos.devices.workstation.bioyond_studio.config import LOCATION_MAPPING
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
# 已删除此导入
|
||||
```
|
||||
|
||||
#### 修改 B: `material_outbound` 方法 (L278-280)
|
||||
|
||||
**修改前**:
|
||||
```python
|
||||
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||
"""指定库位出库物料(通过库位名称)"""
|
||||
location_id = LOCATION_MAPPING.get(location_name, location_name)
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||
"""指定库位出库物料(通过库位名称)"""
|
||||
# location_name 参数实际上应该直接是 location_id (UUID)
|
||||
location_id = location_name
|
||||
```
|
||||
|
||||
**说明**: `LOCATION_MAPPING` 在 `config-0113.py` 中本来就是空字典 `{}`,所以直接使用 `location_name` 逻辑等价。
|
||||
|
||||
---
|
||||
|
||||
## 🎯 关键设计决策
|
||||
|
||||
### 1. 嵌套 vs 扁平配置
|
||||
|
||||
**选择**: 嵌套结构 `config.bioyond_config`
|
||||
|
||||
**理由**:
|
||||
- ✅ 语义清晰,配置分组明确
|
||||
- ✅ 参数传递直观,直接对应 `__init__` 参数
|
||||
- ✅ 易于维护,不需要硬编码字段列表
|
||||
- ✅ 符合 UniLab 设计模式
|
||||
|
||||
### 2. HTTP 服务去重
|
||||
|
||||
**实现**: 子类设置 `_disable_auto_http_service` 标志
|
||||
|
||||
```python
|
||||
# bioyond_cell_workstation.py
|
||||
self.bioyond_config["_disable_auto_http_service"] = True
|
||||
|
||||
# station.py (post_init)
|
||||
if self.bioyond_config.get("_disable_auto_http_service"):
|
||||
logger.info("子类已自行管理HTTP服务,跳过自动启动")
|
||||
return
|
||||
```
|
||||
|
||||
### 3. 全局变量替换策略
|
||||
|
||||
**原则**: 所有配置从 `self.bioyond_config` 获取
|
||||
|
||||
**模式**:
|
||||
```python
|
||||
# 修改前
|
||||
from config import MATERIAL_TYPE_MAPPINGS
|
||||
carrier_type_id = MATERIAL_TYPE_MAPPINGS[board_type][1]
|
||||
|
||||
# 修改后
|
||||
carrier_type_id = self.bioyond_config['material_type_mappings'][board_type][1]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验证结果
|
||||
|
||||
### 启动成功日志
|
||||
```
|
||||
✅ 从 JSON 配置加载 bioyond_config 成功
|
||||
API Host: http://172.16.11.219:44388
|
||||
HTTP Service: 172.16.11.206:8080
|
||||
🔧 已设置 _disable_auto_http_service 标志,防止 HTTP 服务重复启动
|
||||
✅ BioyondCellWorkstation 初始化完成
|
||||
Loaded ResourceTreeSet with 1 trees, 1785 total nodes
|
||||
```
|
||||
|
||||
### 功能验证
|
||||
- ✅ 订单创建 (`create_orders_v2`)
|
||||
- ✅ 质量比计算
|
||||
- ✅ 物料转移 (`transfer_3_to_2_to_1`)
|
||||
- ✅ HTTP 报送接收 (step_finish, sample_finish, order_finish)
|
||||
- ✅ 等待机制 (`wait_for_order_finish`)
|
||||
- ✅ 仓库 UUID 映射
|
||||
- ✅ 物料类型映射
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- **配置迁移经验**: `2026-01-13_JSON配置迁移经验.md`
|
||||
- **任务清单**: `C:\Users\AndyXie\.gemini\antigravity\brain\...\task.md`
|
||||
- **实施计划**: `C:\Users\AndyXie\.gemini\antigravity\brain\...\implementation_plan.md`
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 其他工作站模块
|
||||
|
||||
以下文件仍在使用 `config.py` 全局变量(未包含在本次修改中):
|
||||
- `reaction_station.py` - 使用 `API_CONFIG`
|
||||
- `experiment.py` - 使用 `API_CONFIG`, `WORKFLOW_MAPPINGS`, `MATERIAL_TYPE_MAPPINGS`
|
||||
- `dispensing_station.py` - 使用 `API_CONFIG`, `WAREHOUSE_MAPPING`
|
||||
- `station.py` L176, L177, L529, L530 - 动态导入 `WAREHOUSE_MAPPING`
|
||||
|
||||
**建议**: 后续可以统一迁移这些模块到 JSON 配置。
|
||||
|
||||
### config.py 文件
|
||||
|
||||
`config.py` 文件已恢复但**不再被 bioyond_cell 使用**。可以:
|
||||
- 保留作为其他模块的参考
|
||||
- 或者完全删除(如果其他模块也迁移完成)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 下一步建议
|
||||
|
||||
1. **清理调试代码** ✅ (已完成)
|
||||
2. **提交代码到 Git**
|
||||
3. **迁移其他工作站模块** (可选)
|
||||
4. **更新文档和启动脚本**
|
||||
|
||||
---
|
||||
|
||||
**修改完成日期**: 2026-01-13
|
||||
**系统状态**: ✅ 稳定运行
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -1,157 +0,0 @@
|
||||
# 批量出库 Excel 模板使用说明
|
||||
|
||||
**文件**: `outbound_template.xlsx`
|
||||
**用途**: 配合 `auto_batch_outbound_from_xlsx()` 方法进行批量出库操作
|
||||
**API 端点**: `/api/lims/storage/auto-batch-out-bound`
|
||||
|
||||
---
|
||||
|
||||
## 📋 Excel 列说明
|
||||
|
||||
| 列名 | 说明 | 示例 | 必填 |
|
||||
|------|------|------|------|
|
||||
| `locationId` | **库位 ID(UUID)** | `3a19da43-57b5-294f-d663-154a1cc32270` | ✅ 是 |
|
||||
| `warehouseId` | **仓库 ID 或名称** | `配液站内试剂仓库` | ✅ 是 |
|
||||
| `quantity` | **出库数量** | `1.0`, `2.0` | ✅ 是 |
|
||||
| `x` | **X 坐标(库位横向位置)** | `1`, `2`, `3` | ✅ 是 |
|
||||
| `y` | **Y 坐标(库位纵向位置)** | `1`, `2`, `3` | ✅ 是 |
|
||||
| `z` | **Z 坐标(库位层数/高度)** | `1`, `2`, `3` | ✅ 是 |
|
||||
| `备注说明` | 可选备注信息 | `配液站内试剂仓库-A01` | ❌ 否 |
|
||||
|
||||
### 📐 坐标说明
|
||||
|
||||
**x, y, z** 是库位在仓库内的**三维坐标**:
|
||||
|
||||
```
|
||||
仓库(例如 WH4)
|
||||
├── Z=1(第1层/加样头面)
|
||||
│ ├── X=1, Y=1(位置 A)
|
||||
│ ├── X=2, Y=1(位置 B)
|
||||
│ ├── X=3, Y=1(位置 C)
|
||||
│ └── ...
|
||||
│
|
||||
└── Z=2(第2层/原液瓶面)
|
||||
├── X=1, Y=1(位置 A)
|
||||
├── X=2, Y=1(位置 B)
|
||||
└── ...
|
||||
```
|
||||
|
||||
- **warehouseId**: 指定哪个仓库(WH3, WH4, 配液站等)
|
||||
- **x, y, z**: 在该仓库内的三维坐标
|
||||
- **locationId**: 该坐标位置的唯一 UUID
|
||||
|
||||
### 🎯 起点与终点
|
||||
|
||||
**重要说明**:批量出库模板**只规定了出库的"起点"**(从哪里取物料),**没有指定"终点"**(放到哪里)。
|
||||
|
||||
```
|
||||
出库流程:
|
||||
起点(Excel 指定) → ?终点(LIMS/工作流决定)
|
||||
↓
|
||||
locationId, x, y, z → 由 LIMS 系统或当前工作流自动分配
|
||||
```
|
||||
|
||||
**终点由以下方式确定:**
|
||||
- **LIMS 系统自动分配**:根据当前任务自动规划目标位置
|
||||
- **工作流预定义**:在创建出库任务时已绑定目标位置
|
||||
- **暂存区**:默认放到出库暂存区,等待下一步操作
|
||||
|
||||
💡 **对比**:上料操作(`auto_feeding4to3`)则有 `targetWH` 参数可以指定目标仓库
|
||||
|
||||
---
|
||||
|
||||
## 🔍 如何获取 UUID?
|
||||
|
||||
### 方法 1:从配置文件获取
|
||||
|
||||
参考 `yibin_electrolyte_config.json` 中的 `warehouse_mapping`:
|
||||
|
||||
```json
|
||||
{
|
||||
"warehouse_mapping": {
|
||||
"配液站内试剂仓库": {
|
||||
"site_uuids": {
|
||||
"A01": "3a19da43-57b5-294f-d663-154a1cc32270",
|
||||
"B01": "3a19da43-57b5-7394-5f49-54efe2c9bef2",
|
||||
"C01": "3a19da43-57b5-5e75-552f-8dbd0ad1075f"
|
||||
}
|
||||
},
|
||||
"手动堆栈": {
|
||||
"site_uuids": {
|
||||
"A01": "3a19deae-2c7a-36f5-5e41-02c5b66feaea",
|
||||
"A02": "3a19deae-2c7a-dc6d-c41e-ef285d946cfe"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 方法 2:通过 API 查询
|
||||
|
||||
```python
|
||||
material_info = hardware_interface.material_id_query(workflow_id)
|
||||
locations = material_info.get("locations", [])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 填写示例
|
||||
|
||||
### 示例 1:从配液站内试剂仓库出库
|
||||
|
||||
| locationId | warehouseId | quantity | x | y | z | 备注说明 |
|
||||
|------------|-------------|----------|---|---|---|----------|
|
||||
| `3a19da43-57b5-294f-d663-154a1cc32270` | 配液站内试剂仓库 | 1 | 1 | 1 | 1 | A01 位置 |
|
||||
| `3a19da43-57b5-7394-5f49-54efe2c9bef2` | 配液站内试剂仓库 | 2 | 2 | 1 | 1 | B01 位置 |
|
||||
|
||||
### 示例 2:从手动堆栈出库
|
||||
|
||||
| locationId | warehouseId | quantity | x | y | z | 备注说明 |
|
||||
|------------|-------------|----------|---|---|---|----------|
|
||||
| `3a19deae-2c7a-36f5-5e41-02c5b66feaea` | 手动堆栈 | 1 | 1 | 1 | 1 | A01 |
|
||||
| `3a19deae-2c7a-dc6d-c41e-ef285d946cfe` | 手动堆栈 | 1 | 1 | 2 | 1 | A02 |
|
||||
|
||||
---
|
||||
|
||||
## 💻 使用方法
|
||||
|
||||
```python
|
||||
from bioyond_cell_workstation import BioyondCellWorkstation
|
||||
|
||||
# 初始化工作站
|
||||
workstation = BioyondCellWorkstation(config=config, deck=deck)
|
||||
|
||||
# 调用批量出库方法
|
||||
result = workstation.auto_batch_outbound_from_xlsx(
|
||||
xlsx_path="outbound_template.xlsx"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **locationId 必须是有效的 UUID**,不能使用库位名称
|
||||
2. **x, y, z 坐标必须与 locationId 对应**,表示该库位在仓库内的位置
|
||||
3. **quantity 必须是数字**,可以是整数或浮点数
|
||||
4. Excel 文件必须包含表头行
|
||||
5. 空行会被自动跳过
|
||||
6. 确保 UUID 与实际库位对应,否则 API 会报错
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文件
|
||||
|
||||
- **配置文件**: `yibin_electrolyte_config.json`
|
||||
- **Python 代码**: `bioyond_cell_workstation.py` (L630-695)
|
||||
- **生成脚本**: `create_outbound_template.py`
|
||||
- **上料模板**: `material_template.xlsx`
|
||||
|
||||
---
|
||||
|
||||
## 🔄 重新生成模板
|
||||
|
||||
```bash
|
||||
conda activate newunilab
|
||||
python create_outbound_template.py
|
||||
```
|
||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
||||
from unilabos.device_comms.rpc import BaseRequest
|
||||
from typing import Optional, List, Dict, Any
|
||||
import json
|
||||
|
||||
from unilabos.devices.workstation.bioyond_studio.config import LOCATION_MAPPING
|
||||
|
||||
|
||||
class SimpleLogger:
|
||||
@@ -49,14 +49,6 @@ class BioyondV1RPC(BaseRequest):
|
||||
self.config = config
|
||||
self.api_key = config["api_key"]
|
||||
self.host = config["api_host"]
|
||||
|
||||
# 初始化 location_mapping
|
||||
# 直接从 warehouse_mapping 构建,确保数据源所谓的单一和结构化
|
||||
self.location_mapping = {}
|
||||
warehouse_mapping = self.config.get("warehouse_mapping", {})
|
||||
for warehouse_name, warehouse_config in warehouse_mapping.items():
|
||||
if "site_uuids" in warehouse_config:
|
||||
self.location_mapping.update(warehouse_config["site_uuids"])
|
||||
self._logger = SimpleLogger()
|
||||
self.material_cache = {}
|
||||
self._load_material_cache()
|
||||
@@ -184,40 +176,7 @@ class BioyondV1RPC(BaseRequest):
|
||||
return {}
|
||||
|
||||
print(f"add material data: {response['data']}")
|
||||
|
||||
# 自动更新缓存
|
||||
data = response.get("data", {})
|
||||
if data:
|
||||
if isinstance(data, str):
|
||||
# 如果返回的是字符串,通常是ID
|
||||
mat_id = data
|
||||
name = params.get("name")
|
||||
else:
|
||||
# 如果返回的是字典,尝试获取name和id
|
||||
name = data.get("name") or params.get("name")
|
||||
mat_id = data.get("id")
|
||||
|
||||
if name and mat_id:
|
||||
self.material_cache[name] = mat_id
|
||||
print(f"已自动更新缓存: {name} -> {mat_id}")
|
||||
|
||||
# 处理返回数据中的 details (如果有)
|
||||
# 有些 API 返回结构可能直接包含 details,或者在 data 字段中
|
||||
details = data.get("details", []) if isinstance(data, dict) else []
|
||||
if not details and isinstance(data, dict):
|
||||
details = data.get("detail", [])
|
||||
|
||||
if details:
|
||||
for detail in details:
|
||||
d_name = detail.get("name")
|
||||
# 尝试从不同字段获取 ID
|
||||
d_id = detail.get("id") or detail.get("detailMaterialId")
|
||||
|
||||
if d_name and d_id:
|
||||
self.material_cache[d_name] = d_id
|
||||
print(f"已自动更新 detail 缓存: {d_name} -> {d_id}")
|
||||
|
||||
return data
|
||||
return response.get("data", {})
|
||||
|
||||
def query_matial_type_id(self, data) -> list:
|
||||
"""查找物料typeid"""
|
||||
@@ -244,7 +203,7 @@ class BioyondV1RPC(BaseRequest):
|
||||
params={
|
||||
"apiKey": self.api_key,
|
||||
"requestTime": self.get_current_time_iso8601(),
|
||||
"data": 0,
|
||||
"data": {},
|
||||
})
|
||||
if not response or response['code'] != 1:
|
||||
return []
|
||||
@@ -314,19 +273,11 @@ class BioyondV1RPC(BaseRequest):
|
||||
|
||||
if not response or response['code'] != 1:
|
||||
return {}
|
||||
|
||||
# 自动更新缓存 - 移除被删除的物料
|
||||
for name, mid in list(self.material_cache.items()):
|
||||
if mid == material_id:
|
||||
del self.material_cache[name]
|
||||
print(f"已从缓存移除物料: {name}")
|
||||
break
|
||||
|
||||
return response.get("data", {})
|
||||
|
||||
def material_outbound(self, material_id: str, location_name: str, quantity: int) -> dict:
|
||||
"""指定库位出库物料(通过库位名称)"""
|
||||
location_id = self.location_mapping.get(location_name, location_name)
|
||||
location_id = LOCATION_MAPPING.get(location_name, location_name)
|
||||
|
||||
params = {
|
||||
"materialId": material_id,
|
||||
@@ -1152,10 +1103,6 @@ class BioyondV1RPC(BaseRequest):
|
||||
for detail_material in detail_materials:
|
||||
detail_name = detail_material.get("name")
|
||||
detail_id = detail_material.get("detailMaterialId")
|
||||
if not detail_id:
|
||||
# 尝试其他可能的字段
|
||||
detail_id = detail_material.get("id")
|
||||
|
||||
if detail_name and detail_id:
|
||||
self.material_cache[detail_name] = detail_id
|
||||
print(f"加载detail材料: {detail_name} -> ID: {detail_id}")
|
||||
@@ -1176,14 +1123,6 @@ class BioyondV1RPC(BaseRequest):
|
||||
print(f"从缓存找到材料: {material_name_or_id} -> ID: {material_id}")
|
||||
return material_id
|
||||
|
||||
# 如果缓存中没有,尝试刷新缓存
|
||||
print(f"缓存中未找到材料 '{material_name_or_id}',尝试刷新缓存...")
|
||||
self.refresh_material_cache()
|
||||
if material_name_or_id in self.material_cache:
|
||||
material_id = self.material_cache[material_name_or_id]
|
||||
print(f"刷新缓存后找到材料: {material_name_or_id} -> ID: {material_id}")
|
||||
return material_id
|
||||
|
||||
print(f"警告: 未在缓存中找到材料名称 '{material_name_or_id}',将使用原值")
|
||||
return material_name_or_id
|
||||
|
||||
|
||||
142
unilabos/devices/workstation/bioyond_studio/config.py
Normal file
142
unilabos/devices/workstation/bioyond_studio/config.py
Normal file
@@ -0,0 +1,142 @@
|
||||
# config.py
|
||||
"""
|
||||
配置文件 - 包含所有配置信息和映射关系
|
||||
"""
|
||||
|
||||
# API配置
|
||||
API_CONFIG = {
|
||||
"api_key": "",
|
||||
"api_host": ""
|
||||
}
|
||||
|
||||
# 工作流映射配置
|
||||
WORKFLOW_MAPPINGS = {
|
||||
"reactor_taken_out": "",
|
||||
"reactor_taken_in": "",
|
||||
"Solid_feeding_vials": "",
|
||||
"Liquid_feeding_vials(non-titration)": "",
|
||||
"Liquid_feeding_solvents": "",
|
||||
"Liquid_feeding(titration)": "",
|
||||
"liquid_feeding_beaker": "",
|
||||
"Drip_back": "",
|
||||
}
|
||||
|
||||
# 工作流名称到DisplaySectionName的映射
|
||||
WORKFLOW_TO_SECTION_MAP = {
|
||||
'reactor_taken_in': '反应器放入',
|
||||
'liquid_feeding_beaker': '液体投料-烧杯',
|
||||
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
|
||||
'Liquid_feeding_solvents': '液体投料-溶剂',
|
||||
'Solid_feeding_vials': '固体投料-小瓶',
|
||||
'Liquid_feeding(titration)': '液体投料-滴定',
|
||||
'reactor_taken_out': '反应器取出'
|
||||
}
|
||||
|
||||
# 库位映射配置
|
||||
WAREHOUSE_MAPPING = {
|
||||
"粉末堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
# 样品板
|
||||
"A1": "3a14198e-6929-31f0-8a22-0f98f72260df",
|
||||
"A2": "3a14198e-6929-4379-affa-9a2935c17f99",
|
||||
"A3": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af",
|
||||
"A4": "3a14198e-6929-5e99-2b79-80720f7cfb54",
|
||||
"B1": "3a14198e-6929-f525-9a1b-1857552b28ee",
|
||||
"B2": "3a14198e-6929-bf98-0fd5-26e1d68bf62d",
|
||||
"B3": "3a14198e-6929-2d86-a468-602175a2b5aa",
|
||||
"B4": "3a14198e-6929-1a98-ae57-e97660c489ad",
|
||||
# 分装板
|
||||
"C1": "3a14198e-6929-46fe-841e-03dd753f1e4a",
|
||||
"C2": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95",
|
||||
"C3": "3a14198e-6929-72ac-32ce-9b50245682b8",
|
||||
"C4": "3a14198e-6929-3bd8-e6c7-4a9fd93be118",
|
||||
"D1": "3a14198e-6929-8a0b-b686-6f4a2955c4e2",
|
||||
"D2": "3a14198e-6929-dde1-fc78-34a84b71afdf",
|
||||
"D3": "3a14198e-6929-a0ec-5f15-c0f9f339f963",
|
||||
"D4": "3a14198e-6929-7ac8-915a-fea51cb2e884"
|
||||
}
|
||||
},
|
||||
"溶液堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A1": "3a14198e-d724-e036-afdc-2ae39a7f3383",
|
||||
"A2": "3a14198e-d724-afa4-fc82-0ac8a9016791",
|
||||
"A3": "3a14198e-d724-ca48-bb9e-7e85751e55b6",
|
||||
"A4": "3a14198e-d724-df6d-5e32-5483b3cab583",
|
||||
"B1": "3a14198e-d724-d818-6d4f-5725191a24b5",
|
||||
"B2": "3a14198e-d724-be8a-5e0b-012675e195c6",
|
||||
"B3": "3a14198e-d724-cc1e-5c2c-228a130f40a8",
|
||||
"B4": "3a14198e-d724-1e28-c885-574c3df468d0",
|
||||
"C1": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31",
|
||||
"C2": "3a14198e-d724-ab4e-48cb-817c3c146707",
|
||||
"C3": "3a14198e-d724-7f18-1853-39d0c62e1d33",
|
||||
"C4": "3a14198e-d724-28a2-a760-baa896f46b66",
|
||||
"D1": "3a14198e-d724-d378-d266-2508a224a19f",
|
||||
"D2": "3a14198e-d724-f56e-468b-0110a8feb36a",
|
||||
"D3": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c",
|
||||
"D4": "3a14198e-d724-0ddd-9654-f9352a421de9"
|
||||
}
|
||||
},
|
||||
"试剂堆栈": {
|
||||
"uuid": "",
|
||||
"site_uuids": {
|
||||
"A1": "3a14198c-c2cf-8b40-af28-b467808f1c36",
|
||||
"A2": "3a14198c-c2d0-f3e7-871a-e470d144296f",
|
||||
"A3": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094",
|
||||
"A4": "3a14198c-c2d0-2070-efc8-44e245f10c6f",
|
||||
"B1": "3a14198c-c2d0-354f-39ad-642e1a72fcb8",
|
||||
"B2": "3a14198c-c2d0-1559-105d-0ea30682cab4",
|
||||
"B3": "3a14198c-c2d0-725e-523d-34c037ac2440",
|
||||
"B4": "3a14198c-c2d0-efce-0939-69ca5a7dfd39"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 物料类型配置
|
||||
MATERIAL_TYPE_MAPPINGS = {
|
||||
"烧杯": ("BIOYOND_PolymerStation_1FlaskCarrier", "3a14196b-24f2-ca49-9081-0cab8021bf1a"),
|
||||
"试剂瓶": ("BIOYOND_PolymerStation_1BottleCarrier", ""),
|
||||
"样品板": ("BIOYOND_PolymerStation_6StockCarrier", "3a14196e-b7a0-a5da-1931-35f3000281e9"),
|
||||
"分装板": ("BIOYOND_PolymerStation_6VialCarrier", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"),
|
||||
"样品瓶": ("BIOYOND_PolymerStation_Solid_Stock", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"),
|
||||
"90%分装小瓶": ("BIOYOND_PolymerStation_Solid_Vial", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"),
|
||||
"10%分装小瓶": ("BIOYOND_PolymerStation_Liquid_Vial", "3a14196c-76be-2279-4e22-7310d69aed68"),
|
||||
}
|
||||
|
||||
# 步骤参数配置(各工作流的步骤UUID)
|
||||
WORKFLOW_STEP_IDS = {
|
||||
"reactor_taken_in": {
|
||||
"config": ""
|
||||
},
|
||||
"liquid_feeding_beaker": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
},
|
||||
"liquid_feeding_vials_non_titration": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
},
|
||||
"liquid_feeding_solvents": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
},
|
||||
"solid_feeding_vials": {
|
||||
"feeding": "",
|
||||
"observe": ""
|
||||
},
|
||||
"liquid_feeding_titration": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
},
|
||||
"drip_back": {
|
||||
"liquid": "",
|
||||
"observe": ""
|
||||
}
|
||||
}
|
||||
|
||||
LOCATION_MAPPING = {}
|
||||
|
||||
ACTION_NAMES = {}
|
||||
|
||||
HTTP_SERVICE_CONFIG = {}
|
||||
@@ -1,329 +0,0 @@
|
||||
# config.py
|
||||
"""
|
||||
Bioyond工作站配置文件
|
||||
包含API配置、工作流映射、物料类型映射、仓库库位映射等所有配置信息
|
||||
"""
|
||||
|
||||
from unilabos.resources.bioyond.decks import BIOYOND_PolymerReactionStation_Deck
|
||||
|
||||
# ============================================================================
|
||||
# 基础配置
|
||||
# ============================================================================
|
||||
|
||||
# API配置
|
||||
API_CONFIG = {
|
||||
"api_key": "DE9BDDA0",
|
||||
"api_host": "http://192.168.1.200:44402"
|
||||
}
|
||||
|
||||
# HTTP 报送服务配置
|
||||
HTTP_SERVICE_CONFIG = {
|
||||
"http_service_host": "127.0.0.1", # 监听地址
|
||||
"http_service_port": 8080, # 监听端口
|
||||
}
|
||||
|
||||
# Deck配置 - 反应站工作台配置
|
||||
DECK_CONFIG = BIOYOND_PolymerReactionStation_Deck(setup=True)
|
||||
|
||||
# ============================================================================
|
||||
# 工作流配置
|
||||
# ============================================================================
|
||||
|
||||
# 工作流ID映射
|
||||
WORKFLOW_MAPPINGS = {
|
||||
"reactor_taken_out": "3a16081e-4788-ca37-eff4-ceed8d7019d1",
|
||||
"reactor_taken_in": "3a160df6-76b3-0957-9eb0-cb496d5721c6",
|
||||
"Solid_feeding_vials": "3a160877-87e7-7699-7bc6-ec72b05eb5e6",
|
||||
"Liquid_feeding_vials(non-titration)": "3a167d99-6158-c6f0-15b5-eb030f7d8e47",
|
||||
"Liquid_feeding_solvents": "3a160824-0665-01ed-285a-51ef817a9046",
|
||||
"Liquid_feeding(titration)": "3a16082a-96ac-0449-446a-4ed39f3365b6",
|
||||
"liquid_feeding_beaker": "3a16087e-124f-8ddb-8ec1-c2dff09ca784",
|
||||
"Drip_back": "3a162cf9-6aac-565a-ddd7-682ba1796a4a",
|
||||
}
|
||||
|
||||
# 工作流名称到显示名称的映射
|
||||
WORKFLOW_TO_SECTION_MAP = {
|
||||
'reactor_taken_in': '反应器放入',
|
||||
'reactor_taken_out': '反应器取出',
|
||||
'Solid_feeding_vials': '固体投料-小瓶',
|
||||
'Liquid_feeding_vials(non-titration)': '液体投料-小瓶(非滴定)',
|
||||
'Liquid_feeding_solvents': '液体投料-溶剂',
|
||||
'Liquid_feeding(titration)': '液体投料-滴定',
|
||||
'liquid_feeding_beaker': '液体投料-烧杯',
|
||||
'Drip_back': '液体回滴'
|
||||
}
|
||||
|
||||
# 工作流步骤ID配置
|
||||
WORKFLOW_STEP_IDS = {
|
||||
"reactor_taken_in": {
|
||||
"config": "60a06f85-c5b3-29eb-180f-4f62dd7e2154"
|
||||
},
|
||||
"liquid_feeding_beaker": {
|
||||
"liquid": "6808cda7-fee7-4092-97f0-5f9c2ffa60e3",
|
||||
"observe": "1753c0de-dffc-4ee6-8458-805a2e227362"
|
||||
},
|
||||
"liquid_feeding_vials_non_titration": {
|
||||
"liquid": "62ea6e95-3d5d-43db-bc1e-9a1802673861",
|
||||
"observe": "3a167d99-6172-b67b-5f22-a7892197142e"
|
||||
},
|
||||
"liquid_feeding_solvents": {
|
||||
"liquid": "1fcea355-2545-462b-b727-350b69a313bf",
|
||||
"observe": "0553dfb3-9ac5-4ace-8e00-2f11029919a8"
|
||||
},
|
||||
"solid_feeding_vials": {
|
||||
"feeding": "f7ae7448-4f20-4c1d-8096-df6fbadd787a",
|
||||
"observe": "263c7ed5-7277-426b-bdff-d6fbf77bcc05"
|
||||
},
|
||||
"liquid_feeding_titration": {
|
||||
"liquid": "a00ec41b-e666-4422-9c20-bfcd3cd15c54",
|
||||
"observe": "ac738ff6-4c58-4155-87b1-d6f65a2c9ab5"
|
||||
},
|
||||
"drip_back": {
|
||||
"liquid": "371be86a-ab77-4769-83e5-54580547c48a",
|
||||
"observe": "ce024b9d-bd20-47b8-9f78-ca5ce7f44cf1"
|
||||
}
|
||||
}
|
||||
|
||||
# 工作流动作名称配置
|
||||
ACTION_NAMES = {
|
||||
"reactor_taken_in": {
|
||||
"config": "通量-配置",
|
||||
"stirring": "反应模块-开始搅拌"
|
||||
},
|
||||
"solid_feeding_vials": {
|
||||
"feeding": "粉末加样模块-投料",
|
||||
"observe": "反应模块-观察搅拌结果"
|
||||
},
|
||||
"liquid_feeding_vials_non_titration": {
|
||||
"liquid": "稀释液瓶加液位-液体投料",
|
||||
"observe": "反应模块-滴定结果观察"
|
||||
},
|
||||
"liquid_feeding_solvents": {
|
||||
"liquid": "试剂AB放置位-试剂吸液分液",
|
||||
"observe": "反应模块-观察搅拌结果"
|
||||
},
|
||||
"liquid_feeding_titration": {
|
||||
"liquid": "稀释液瓶加液位-稀释液吸液分液",
|
||||
"observe": "反应模块-滴定结果观察"
|
||||
},
|
||||
"liquid_feeding_beaker": {
|
||||
"liquid": "烧杯溶液放置位-烧杯吸液分液",
|
||||
"observe": "反应模块-观察搅拌结果"
|
||||
},
|
||||
"drip_back": {
|
||||
"liquid": "试剂AB放置位-试剂吸液分液",
|
||||
"observe": "反应模块-向下滴定结果观察"
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 仓库配置
|
||||
# ============================================================================
|
||||
# 说明:
|
||||
# - 出库和入库操作都需要UUID
|
||||
WAREHOUSE_MAPPING = {
|
||||
# ========== 反应站仓库 ==========
|
||||
|
||||
# 堆栈1左 - 反应站左侧堆栈 (4行×4列=16个库位, A01~D04)
|
||||
"堆栈1左": {
|
||||
"uuid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
|
||||
"site_uuids": {
|
||||
"A01": "3a14aa17-0d49-11d7-a6e1-f236b3e5e5a3",
|
||||
"A02": "3a14aa17-0d49-4bc5-8836-517b75473f5f",
|
||||
"A03": "3a14aa17-0d49-c2bc-6222-5cee8d2d94f8",
|
||||
"A04": "3a14aa17-0d49-3ce2-8e9a-008c38d116fb",
|
||||
"B01": "3a14aa17-0d49-f49c-6b66-b27f185a3b32",
|
||||
"B02": "3a14aa17-0d49-cf46-df85-a979c9c9920c",
|
||||
"B03": "3a14aa17-0d49-7698-4a23-f7ffb7d48ba3",
|
||||
"B04": "3a14aa17-0d49-1231-99be-d5870e6478e9",
|
||||
"C01": "3a14aa17-0d49-be34-6fae-4aed9d48b70b",
|
||||
"C02": "3a14aa17-0d49-11d7-0897-34921dcf6b7c",
|
||||
"C03": "3a14aa17-0d49-9840-0bd5-9c63c1bb2c29",
|
||||
"C04": "3a14aa17-0d49-8335-3bff-01da69ea4911",
|
||||
"D01": "3a14aa17-0d49-2bea-c8e5-2b32094935d5",
|
||||
"D02": "3a14aa17-0d49-cff4-e9e8-5f5f0bc1ef32",
|
||||
"D03": "3a14aa17-0d49-4948-cb0a-78f30d1ca9b8",
|
||||
"D04": "3a14aa17-0d49-fd2f-9dfb-a29b11e84099",
|
||||
},
|
||||
},
|
||||
|
||||
# 堆栈1右 - 反应站右侧堆栈 (4行×4列=16个库位, A05~D08)
|
||||
"堆栈1右": {
|
||||
"uuid": "3a14aa17-0d49-dce4-486e-4b5c85c8b366",
|
||||
"site_uuids": {
|
||||
"A05": "3a14aa17-0d49-2c61-edc8-72a8ca7192dd",
|
||||
"A06": "3a14aa17-0d49-60c8-2b00-40b17198f397",
|
||||
"A07": "3a14aa17-0d49-ec5b-0b75-634dce8eed25",
|
||||
"A08": "3a14aa17-0d49-3ec9-55b3-f3189c4ec53d",
|
||||
"B05": "3a14aa17-0d49-6a4e-abcf-4c113eaaeaad",
|
||||
"B06": "3a14aa17-0d49-e3f6-2dd6-28c2e8194fbe",
|
||||
"B07": "3a14aa17-0d49-11a6-b861-ee895121bf52",
|
||||
"B08": "3a14aa17-0d49-9c7d-1145-d554a6e482f0",
|
||||
"C05": "3a14aa17-0d49-45c4-7a34-5105bc3e2368",
|
||||
"C06": "3a14aa17-0d49-867e-39ab-31b3fe9014be",
|
||||
"C07": "3a14aa17-0d49-ec56-c4b4-39fd9b2131e7",
|
||||
"C08": "3a14aa17-0d49-1128-d7d9-ffb1231c98c0",
|
||||
"D05": "3a14aa17-0d49-e843-f961-ea173326a14b",
|
||||
"D06": "3a14aa17-0d49-4d26-a985-f188359c4f8b",
|
||||
"D07": "3a14aa17-0d49-223a-b520-bc092bb42fe0",
|
||||
"D08": "3a14aa17-0d49-4fa3-401a-6a444e1cca22",
|
||||
},
|
||||
},
|
||||
|
||||
# 站内试剂存放堆栈
|
||||
"站内试剂存放堆栈": {
|
||||
"uuid": "3a14aa3b-9fab-9d8e-d1a7-828f01f51f0c",
|
||||
"site_uuids": {
|
||||
"A01": "3a14aa3b-9fab-adac-7b9c-e1ee446b51d5",
|
||||
"A02": "3a14aa3b-9fab-ca72-febc-b7c304476c78"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
# 测量小瓶仓库(测密度)
|
||||
"测量小瓶仓库": {
|
||||
"uuid": "3a15012f-705b-c0de-3f9e-950c205f9921",
|
||||
"site_uuids": {
|
||||
"A01": "3a15012f-705e-0524-3161-c523b5aebc97",
|
||||
"A02": "3a15012f-705e-7cd1-32ab-ad4fd1ab75c8",
|
||||
"A03": "3a15012f-705e-a5d6-edac-bdbfec236260",
|
||||
"B01": "3a15012f-705e-e0ee-80e0-10a6b3fc500d",
|
||||
"B02": "3a15012f-705e-e499-180d-de06d60d0b21",
|
||||
"B03": "3a15012f-705e-eff6-63f1-09f742096b26"
|
||||
}
|
||||
},
|
||||
|
||||
# 站内Tip盒堆栈 - 用于存放枪头盒 (耗材)
|
||||
"站内Tip盒堆栈": {
|
||||
"uuid": "3a14aa3a-2d3c-b5c1-9ddf-7c4a957d459a",
|
||||
"site_uuids": {
|
||||
"A01": "3a14aa3a-2d3d-e700-411a-0ddf85e1f18a",
|
||||
"A02": "3a14aa3a-2d3d-a7ce-099a-d5632fdafa24",
|
||||
"A03": "3a14aa3a-2d3d-bdf6-a702-c60b38b08501",
|
||||
"B01": "3a14aa3a-2d3d-d704-f076-2a8d5bc72cb8",
|
||||
"B02": "3a14aa3a-2d3d-c350-2526-0778d173a5ac",
|
||||
"B03": "3a14aa3a-2d3d-bc38-b356-f0de2e44e0c7"
|
||||
}
|
||||
},
|
||||
# ========== 配液站仓库 ==========
|
||||
"粉末堆栈": {
|
||||
"uuid": "3a14198e-6928-121f-7ca6-88ad3ae7e6a0",
|
||||
"site_uuids": {
|
||||
"A01": "3a14198e-6929-31f0-8a22-0f98f72260df",
|
||||
"A02": "3a14198e-6929-4379-affa-9a2935c17f99",
|
||||
"A03": "3a14198e-6929-56da-9a1c-7f5fbd4ae8af",
|
||||
"A04": "3a14198e-6929-5e99-2b79-80720f7cfb54",
|
||||
"B01": "3a14198e-6929-f525-9a1b-1857552b28ee",
|
||||
"B02": "3a14198e-6929-bf98-0fd5-26e1d68bf62d",
|
||||
"B03": "3a14198e-6929-2d86-a468-602175a2b5aa",
|
||||
"B04": "3a14198e-6929-1a98-ae57-e97660c489ad",
|
||||
"C01": "3a14198e-6929-46fe-841e-03dd753f1e4a",
|
||||
"C02": "3a14198e-6929-72ac-32ce-9b50245682b8",
|
||||
"C03": "3a14198e-6929-8a0b-b686-6f4a2955c4e2",
|
||||
"C04": "3a14198e-6929-a0ec-5f15-c0f9f339f963",
|
||||
"D01": "3a14198e-6929-1bc9-a9bd-3b7ca66e7f95",
|
||||
"D02": "3a14198e-6929-3bd8-e6c7-4a9fd93be118",
|
||||
"D03": "3a14198e-6929-dde1-fc78-34a84b71afdf",
|
||||
"D04": "3a14198e-6929-7ac8-915a-fea51cb2e884"
|
||||
}
|
||||
},
|
||||
"溶液堆栈": {
|
||||
"uuid": "3a14198e-d723-2c13-7d12-50143e190a23",
|
||||
"site_uuids": {
|
||||
"A01": "3a14198e-d724-e036-afdc-2ae39a7f3383",
|
||||
"A02": "3a14198e-d724-d818-6d4f-5725191a24b5",
|
||||
"A03": "3a14198e-d724-b5bb-adf3-4c5a0da6fb31",
|
||||
"A04": "3a14198e-d724-d378-d266-2508a224a19f",
|
||||
"B01": "3a14198e-d724-afa4-fc82-0ac8a9016791",
|
||||
"B02": "3a14198e-d724-be8a-5e0b-012675e195c6",
|
||||
"B03": "3a14198e-d724-ab4e-48cb-817c3c146707",
|
||||
"B04": "3a14198e-d724-f56e-468b-0110a8feb36a",
|
||||
"C01": "3a14198e-d724-ca48-bb9e-7e85751e55b6",
|
||||
"C02": "3a14198e-d724-cc1e-5c2c-228a130f40a8",
|
||||
"C03": "3a14198e-d724-7f18-1853-39d0c62e1d33",
|
||||
"C04": "3a14198e-d724-0cf1-dea9-a1f40fe7e13c",
|
||||
"D01": "3a14198e-d724-df6d-5e32-5483b3cab583",
|
||||
"D02": "3a14198e-d724-1e28-c885-574c3df468d0",
|
||||
"D03": "3a14198e-d724-28a2-a760-baa896f46b66",
|
||||
"D04": "3a14198e-d724-0ddd-9654-f9352a421de9"
|
||||
}
|
||||
},
|
||||
"试剂堆栈": {
|
||||
"uuid": "3a14198c-c2cc-0290-e086-44a428fba248",
|
||||
"site_uuids": {
|
||||
"A01": "3a14198c-c2cf-8b40-af28-b467808f1c36", # x=1, y=1, code=0001-0001
|
||||
"A02": "3a14198c-c2d0-dc7d-b8d0-e1d88cee3094", # x=1, y=2, code=0001-0002
|
||||
"A03": "3a14198c-c2d0-354f-39ad-642e1a72fcb8", # x=1, y=3, code=0001-0003
|
||||
"A04": "3a14198c-c2d0-725e-523d-34c037ac2440", # x=1, y=4, code=0001-0004
|
||||
"B01": "3a14198c-c2d0-f3e7-871a-e470d144296f", # x=2, y=1, code=0001-0005
|
||||
"B02": "3a14198c-c2d0-2070-efc8-44e245f10c6f", # x=2, y=2, code=0001-0006
|
||||
"B03": "3a14198c-c2d0-1559-105d-0ea30682cab4", # x=2, y=3, code=0001-0007
|
||||
"B04": "3a14198c-c2d0-efce-0939-69ca5a7dfd39" # x=2, y=4, code=0001-0008
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 物料类型配置
|
||||
# ============================================================================
|
||||
# 说明:
|
||||
# - 格式: PyLabRobot资源类型名称 → Bioyond系统typeId的UUID
|
||||
# - 这个映射基于 resource.model 属性 (不是显示名称!)
|
||||
# - UUID为空表示该类型暂未在Bioyond系统中定义
|
||||
MATERIAL_TYPE_MAPPINGS = {
|
||||
# ================================================配液站资源============================================================
|
||||
# ==================================================样品===============================================================
|
||||
"BIOYOND_PolymerStation_1FlaskCarrier": ("烧杯", "3a14196b-24f2-ca49-9081-0cab8021bf1a"), # 配液站-样品-烧杯
|
||||
"BIOYOND_PolymerStation_1BottleCarrier": ("试剂瓶", "3a14196b-8bcf-a460-4f74-23f21ca79e72"), # 配液站-样品-试剂瓶
|
||||
"BIOYOND_PolymerStation_6StockCarrier": ("分装板", "3a14196e-5dfe-6e21-0c79-fe2036d052c4"), # 配液站-样品-分装板
|
||||
"BIOYOND_PolymerStation_Liquid_Vial": ("10%分装小瓶", "3a14196c-76be-2279-4e22-7310d69aed68"), # 配液站-样品-分装板-第一排小瓶
|
||||
"BIOYOND_PolymerStation_Solid_Vial": ("90%分装小瓶", "3a14196c-cdcf-088d-dc7d-5cf38f0ad9ea"), # 配液站-样品-分装板-第二排小瓶
|
||||
# ==================================================试剂===============================================================
|
||||
"BIOYOND_PolymerStation_8StockCarrier": ("样品板", "3a14196e-b7a0-a5da-1931-35f3000281e9"), # 配液站-试剂-样品板(8孔)
|
||||
"BIOYOND_PolymerStation_Solid_Stock": ("样品瓶", "3a14196a-cf7d-8aea-48d8-b9662c7dba94"), # 配液站-试剂-样品板-样品瓶
|
||||
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 动态生成的库位UUID映射(从WAREHOUSE_MAPPING中提取)
|
||||
# ============================================================================
|
||||
|
||||
LOCATION_MAPPING = {}
|
||||
for warehouse_name, warehouse_config in WAREHOUSE_MAPPING.items():
|
||||
if "site_uuids" in warehouse_config:
|
||||
LOCATION_MAPPING.update(warehouse_config["site_uuids"])
|
||||
|
||||
# ============================================================================
|
||||
# 物料默认参数配置
|
||||
# ============================================================================
|
||||
# 说明:
|
||||
# - 为特定物料名称自动添加默认参数(如密度、分子量、单位等)
|
||||
# - 格式: 物料名称 → {参数字典}
|
||||
# - 在创建或更新物料时,会自动合并这些参数到 Parameters 字段
|
||||
# - unit: 物料的计量单位(会用于 unit 字段)
|
||||
# - density/densityUnit: 密度信息(会添加到 Parameters 中)
|
||||
|
||||
MATERIAL_DEFAULT_PARAMETERS = {
|
||||
# 溶剂类
|
||||
"NMP": {
|
||||
"unit": "毫升",
|
||||
"density": "1.03",
|
||||
"densityUnit": "g/mL",
|
||||
"description": "N-甲基吡咯烷酮 (N-Methyl-2-pyrrolidone)"
|
||||
},
|
||||
# 可以继续添加其他物料...
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 物料类型默认参数配置
|
||||
# ============================================================================
|
||||
# 说明:
|
||||
# - 为特定物料类型(UUID)自动添加默认参数
|
||||
# - 格式: Bioyond类型UUID → {参数字典}
|
||||
# - 优先级低于按名称匹配的配置
|
||||
MATERIAL_TYPE_PARAMETERS = {
|
||||
# 示例:
|
||||
# "3a14196b-24f2-ca49-9081-0cab8021bf1a": { # 烧杯
|
||||
# "unit": "个"
|
||||
# }
|
||||
}
|
||||
@@ -4,8 +4,7 @@ import time
|
||||
from typing import Optional, Dict, Any, List
|
||||
from typing_extensions import TypedDict
|
||||
import requests
|
||||
import pint
|
||||
|
||||
from unilabos.devices.workstation.bioyond_studio.config import API_CONFIG
|
||||
|
||||
from unilabos.devices.workstation.bioyond_studio.bioyond_rpc import BioyondException
|
||||
from unilabos.devices.workstation.bioyond_studio.station import BioyondWorkstation
|
||||
@@ -26,89 +25,13 @@ class ComputeExperimentDesignReturn(TypedDict):
|
||||
class BioyondDispensingStation(BioyondWorkstation):
|
||||
def __init__(
|
||||
self,
|
||||
config: dict = None,
|
||||
deck=None,
|
||||
protocol_type=None,
|
||||
config,
|
||||
# 桌子
|
||||
deck,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
"""初始化配液站
|
||||
|
||||
Args:
|
||||
config: 配置字典,应包含material_type_mappings等配置
|
||||
deck: Deck对象
|
||||
protocol_type: 协议类型(由ROS系统传递,此处忽略)
|
||||
**kwargs: 其他可能的参数
|
||||
"""
|
||||
if config is None:
|
||||
config = {}
|
||||
|
||||
# 将 kwargs 合并到 config 中 (处理扁平化配置如 api_key)
|
||||
config.update(kwargs)
|
||||
|
||||
if deck is None and config:
|
||||
deck = config.get('deck')
|
||||
|
||||
# 🔧 修复: 确保 Deck 上的 warehouses 具有正确的 UUID (必须在 super().__init__ 之前执行,因为父类会触发同步)
|
||||
# 从配置中读取 warehouse_mapping,并应用到实际的 deck 资源上
|
||||
if config and "warehouse_mapping" in config and deck:
|
||||
warehouse_mapping = config["warehouse_mapping"]
|
||||
print(f"正在根据配置更新 Deck warehouse UUIDs... (共有 {len(warehouse_mapping)} 个配置)")
|
||||
|
||||
user_deck = deck
|
||||
# 初始化 warehouses 字典
|
||||
if not hasattr(user_deck, "warehouses") or user_deck.warehouses is None:
|
||||
user_deck.warehouses = {}
|
||||
|
||||
# 1. 尝试从 children 中查找匹配的资源
|
||||
for child in user_deck.children:
|
||||
# 简单判断: 如果名字在 mapping 中,就认为是 warehouse
|
||||
if child.name in warehouse_mapping:
|
||||
user_deck.warehouses[child.name] = child
|
||||
print(f" - 从子资源中找到 warehouse: {child.name}")
|
||||
|
||||
# 2. 如果还是没找到,且 Deck 类有 setup 方法,尝试调用 setup (针对 Deck 对象正确但未初始化的情况)
|
||||
if not user_deck.warehouses and hasattr(user_deck, "setup"):
|
||||
print(" - 尝试调用 deck.setup() 初始化仓库...")
|
||||
try:
|
||||
user_deck.setup()
|
||||
# setup 后重新检查
|
||||
if hasattr(user_deck, "warehouses") and user_deck.warehouses:
|
||||
print(f" - setup() 成功,找到 {len(user_deck.warehouses)} 个仓库")
|
||||
except Exception as e:
|
||||
print(f" - 调用 setup() 失败: {e}")
|
||||
|
||||
# 3. 如果仍然为空,可能需要手动创建 (仅针对特定已知的 Deck 类型进行补救,这里暂时只打印警告)
|
||||
if not user_deck.warehouses:
|
||||
print(" - ⚠️ 仍然无法找到任何 warehouse 资源!")
|
||||
|
||||
for wh_name, wh_config in warehouse_mapping.items():
|
||||
target_uuid = wh_config.get("uuid")
|
||||
|
||||
# 尝试在 deck.warehouses 中查找
|
||||
wh_resource = None
|
||||
if hasattr(user_deck, "warehouses") and wh_name in user_deck.warehouses:
|
||||
wh_resource = user_deck.warehouses[wh_name]
|
||||
|
||||
# 如果没找到,尝试在所有子资源中查找
|
||||
if not wh_resource:
|
||||
wh_resource = user_deck.get_resource(wh_name)
|
||||
|
||||
if wh_resource:
|
||||
if target_uuid:
|
||||
current_uuid = getattr(wh_resource, "uuid", None)
|
||||
print(f"✅ 更新仓库 '{wh_name}' UUID: {current_uuid} -> {target_uuid}")
|
||||
|
||||
# 动态添加 uuid 属性
|
||||
wh_resource.uuid = target_uuid
|
||||
# 同时也确保 category 正确,避免 graphio 识别错误
|
||||
# wh_resource.category = "warehouse"
|
||||
else:
|
||||
print(f"⚠️ 仓库 '{wh_name}' 在配置中没有 UUID")
|
||||
else:
|
||||
print(f"❌ 在 Deck 中未找到配置的仓库: '{wh_name}'")
|
||||
|
||||
super().__init__(bioyond_config=config, deck=deck)
|
||||
|
||||
):
|
||||
super().__init__(config, deck, *args, **kwargs)
|
||||
# self.config = config
|
||||
# self.api_key = config["api_key"]
|
||||
# self.host = config["api_host"]
|
||||
@@ -120,41 +43,6 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
# 用于跟踪任务完成状态的字典: {orderCode: {status, order_id, timestamp}}
|
||||
self.order_completion_status = {}
|
||||
|
||||
# 初始化 pint 单位注册表
|
||||
self.ureg = pint.UnitRegistry()
|
||||
|
||||
# 化合物信息
|
||||
self.compound_info = {
|
||||
"MolWt": {
|
||||
"MDA": 108.14 * self.ureg.g / self.ureg.mol,
|
||||
"TDA": 122.16 * self.ureg.g / self.ureg.mol,
|
||||
"PAPP": 521.62 * self.ureg.g / self.ureg.mol,
|
||||
"BTDA": 322.23 * self.ureg.g / self.ureg.mol,
|
||||
"BPDA": 294.22 * self.ureg.g / self.ureg.mol,
|
||||
"6FAP": 366.26 * self.ureg.g / self.ureg.mol,
|
||||
"PMDA": 218.12 * self.ureg.g / self.ureg.mol,
|
||||
"MPDA": 108.14 * self.ureg.g / self.ureg.mol,
|
||||
"SIDA": 248.51 * self.ureg.g / self.ureg.mol,
|
||||
"ODA": 200.236 * self.ureg.g / self.ureg.mol,
|
||||
"4,4'-ODA": 200.236 * self.ureg.g / self.ureg.mol,
|
||||
"134": 292.34 * self.ureg.g / self.ureg.mol,
|
||||
},
|
||||
"FuncGroup": {
|
||||
"MDA": "Amine",
|
||||
"TDA": "Amine",
|
||||
"PAPP": "Amine",
|
||||
"BTDA": "Anhydride",
|
||||
"BPDA": "Anhydride",
|
||||
"6FAP": "Amine",
|
||||
"MPDA": "Amine",
|
||||
"SIDA": "Amine",
|
||||
"PMDA": "Anhydride",
|
||||
"ODA": "Amine",
|
||||
"4,4'-ODA": "Amine",
|
||||
"134": "Amine",
|
||||
}
|
||||
}
|
||||
|
||||
def _post_project_api(self, endpoint: str, data: Any) -> Dict[str, Any]:
|
||||
"""项目接口通用POST调用
|
||||
|
||||
@@ -166,7 +54,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
dict: 服务端响应,失败时返回 {code:0,message,...}
|
||||
"""
|
||||
request_data = {
|
||||
"apiKey": self.bioyond_config["api_key"],
|
||||
"apiKey": API_CONFIG["api_key"],
|
||||
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
||||
"data": data
|
||||
}
|
||||
@@ -197,7 +85,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
dict: 服务端响应,失败时返回 {code:0,message,...}
|
||||
"""
|
||||
request_data = {
|
||||
"apiKey": self.bioyond_config["api_key"],
|
||||
"apiKey": API_CONFIG["api_key"],
|
||||
"requestTime": self.hardware_interface.get_current_time_iso8601(),
|
||||
"data": data
|
||||
}
|
||||
@@ -230,22 +118,20 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
ratio = json.loads(ratio)
|
||||
except Exception:
|
||||
ratio = {}
|
||||
root = str(Path(__file__).resolve().parents[3])
|
||||
if root not in sys.path:
|
||||
sys.path.append(root)
|
||||
try:
|
||||
mod = importlib.import_module("tem.compute")
|
||||
except Exception as e:
|
||||
raise BioyondException(f"无法导入计算模块: {e}")
|
||||
try:
|
||||
wp = float(wt_percent) if isinstance(wt_percent, str) else wt_percent
|
||||
mt = float(m_tot) if isinstance(m_tot, str) else m_tot
|
||||
tp = float(titration_percent) if isinstance(titration_percent, str) else titration_percent
|
||||
except Exception as e:
|
||||
raise BioyondException(f"参数解析失败: {e}")
|
||||
|
||||
# 2. 调用内部计算方法
|
||||
res = self._generate_experiment_design(
|
||||
ratio=ratio,
|
||||
wt_percent=wp,
|
||||
m_tot=mt,
|
||||
titration_percent=tp
|
||||
)
|
||||
|
||||
# 3. 构造返回结果
|
||||
res = mod.generate_experiment_design(ratio=ratio, wt_percent=wp, m_tot=mt, titration_percent=tp)
|
||||
out = {
|
||||
"solutions": res.get("solutions", []),
|
||||
"titration": res.get("titration", {}),
|
||||
@@ -254,248 +140,11 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
"return_info": json.dumps(res, ensure_ascii=False)
|
||||
}
|
||||
return out
|
||||
|
||||
except BioyondException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise BioyondException(str(e))
|
||||
|
||||
def _generate_experiment_design(
|
||||
self,
|
||||
ratio: dict,
|
||||
wt_percent: float = 0.25,
|
||||
m_tot: float = 70,
|
||||
titration_percent: float = 0.03,
|
||||
) -> dict:
|
||||
"""内部方法:生成实验设计
|
||||
|
||||
根据FuncGroup自动区分二胺和二酐,每种二胺单独配溶液,严格按照ratio顺序投料。
|
||||
|
||||
参数:
|
||||
ratio: 化合物配比字典,格式: {"compound_name": ratio_value}
|
||||
wt_percent: 固体重量百分比
|
||||
m_tot: 反应混合物总质量(g)
|
||||
titration_percent: 滴定溶液百分比
|
||||
|
||||
返回:
|
||||
包含实验设计详细参数的字典
|
||||
"""
|
||||
# 溶剂密度
|
||||
ρ_solvent = 1.03 * self.ureg.g / self.ureg.ml
|
||||
# 二酐溶解度
|
||||
solubility = 0.02 * self.ureg.g / self.ureg.ml
|
||||
# 投入固体时最小溶剂体积
|
||||
V_min = 30 * self.ureg.ml
|
||||
m_tot = m_tot * self.ureg.g
|
||||
|
||||
# 保持ratio中的顺序
|
||||
compound_names = list(ratio.keys())
|
||||
compound_ratios = list(ratio.values())
|
||||
|
||||
# 验证所有化合物是否在 compound_info 中定义
|
||||
undefined_compounds = [name for name in compound_names if name not in self.compound_info["MolWt"]]
|
||||
if undefined_compounds:
|
||||
available = list(self.compound_info["MolWt"].keys())
|
||||
raise ValueError(
|
||||
f"以下化合物未在 compound_info 中定义: {undefined_compounds}。"
|
||||
f"可用的化合物: {available}"
|
||||
)
|
||||
|
||||
# 获取各化合物的分子量和官能团类型
|
||||
molecular_weights = [self.compound_info["MolWt"][name] for name in compound_names]
|
||||
func_groups = [self.compound_info["FuncGroup"][name] for name in compound_names]
|
||||
|
||||
# 记录化合物信息用于调试
|
||||
self.hardware_interface._logger.info(f"化合物名称: {compound_names}")
|
||||
self.hardware_interface._logger.info(f"官能团类型: {func_groups}")
|
||||
|
||||
# 按原始顺序分离二胺和二酐
|
||||
ordered_compounds = list(zip(compound_names, compound_ratios, molecular_weights, func_groups))
|
||||
diamine_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Amine"]
|
||||
anhydride_compounds = [(name, ratio_val, mw, i) for i, (name, ratio_val, mw, fg) in enumerate(ordered_compounds) if fg == "Anhydride"]
|
||||
|
||||
if not diamine_compounds or not anhydride_compounds:
|
||||
raise ValueError(
|
||||
f"需要同时包含二胺(Amine)和二酐(Anhydride)化合物。"
|
||||
f"当前二胺: {[c[0] for c in diamine_compounds]}, "
|
||||
f"当前二酐: {[c[0] for c in anhydride_compounds]}"
|
||||
)
|
||||
|
||||
# 计算加权平均分子量 (基于摩尔比)
|
||||
total_molar_ratio = sum(compound_ratios)
|
||||
weighted_molecular_weight = sum(ratio_val * mw for ratio_val, mw in zip(compound_ratios, molecular_weights))
|
||||
|
||||
# 取最后一个二酐用于滴定
|
||||
titration_anhydride = anhydride_compounds[-1]
|
||||
solid_anhydrides = anhydride_compounds[:-1] if len(anhydride_compounds) > 1 else []
|
||||
|
||||
# 二胺溶液配制参数 - 每种二胺单独配制
|
||||
diamine_solutions = []
|
||||
total_diamine_volume = 0 * self.ureg.ml
|
||||
|
||||
# 计算反应物的总摩尔量
|
||||
n_reactant = m_tot * wt_percent / weighted_molecular_weight
|
||||
|
||||
for name, ratio_val, mw, order_index in diamine_compounds:
|
||||
# 跳过 SIDA
|
||||
if name == "SIDA":
|
||||
continue
|
||||
|
||||
# 计算该二胺需要的摩尔数
|
||||
n_diamine_needed = n_reactant * ratio_val
|
||||
|
||||
# 二胺溶液配制参数 (每种二胺固定配制参数)
|
||||
m_diamine_solid = 5.0 * self.ureg.g # 每种二胺固体质量
|
||||
V_solvent_for_this = 20 * self.ureg.ml # 每种二胺溶剂体积
|
||||
m_solvent_for_this = ρ_solvent * V_solvent_for_this
|
||||
|
||||
# 计算该二胺溶液的浓度
|
||||
c_diamine = (m_diamine_solid / mw) / V_solvent_for_this
|
||||
|
||||
# 计算需要移取的溶液体积
|
||||
V_diamine_needed = n_diamine_needed / c_diamine
|
||||
|
||||
diamine_solutions.append({
|
||||
"name": name,
|
||||
"order": order_index,
|
||||
"solid_mass": m_diamine_solid.magnitude,
|
||||
"solvent_volume": V_solvent_for_this.magnitude,
|
||||
"concentration": c_diamine.magnitude,
|
||||
"volume_needed": V_diamine_needed.magnitude,
|
||||
"molar_ratio": ratio_val
|
||||
})
|
||||
|
||||
total_diamine_volume += V_diamine_needed
|
||||
|
||||
# 按原始顺序排序
|
||||
diamine_solutions.sort(key=lambda x: x["order"])
|
||||
|
||||
# 计算滴定二酐的质量
|
||||
titration_name, titration_ratio, titration_mw, _ = titration_anhydride
|
||||
m_titration_anhydride = n_reactant * titration_ratio * titration_mw
|
||||
m_titration_90 = m_titration_anhydride * (1 - titration_percent)
|
||||
m_titration_10 = m_titration_anhydride * titration_percent
|
||||
|
||||
# 计算其他固体二酐的质量 (按顺序)
|
||||
solid_anhydride_masses = []
|
||||
for name, ratio_val, mw, order_index in solid_anhydrides:
|
||||
mass = n_reactant * ratio_val * mw
|
||||
solid_anhydride_masses.append({
|
||||
"name": name,
|
||||
"order": order_index,
|
||||
"mass": mass.magnitude,
|
||||
"molar_ratio": ratio_val
|
||||
})
|
||||
|
||||
# 按原始顺序排序
|
||||
solid_anhydride_masses.sort(key=lambda x: x["order"])
|
||||
|
||||
# 计算溶剂用量
|
||||
total_diamine_solution_mass = sum(
|
||||
sol["volume_needed"] * ρ_solvent for sol in diamine_solutions
|
||||
) * self.ureg.ml
|
||||
|
||||
# 预估滴定溶剂量、计算补加溶剂量
|
||||
m_solvent_titration = m_titration_10 / solubility * ρ_solvent
|
||||
m_solvent_add = m_tot * (1 - wt_percent) - total_diamine_solution_mass - m_solvent_titration
|
||||
|
||||
# 检查最小溶剂体积要求
|
||||
total_liquid_volume = (total_diamine_solution_mass + m_solvent_add) / ρ_solvent
|
||||
m_tot_min = V_min / total_liquid_volume * m_tot
|
||||
|
||||
# 如果需要,按比例放大
|
||||
scale_factor = 1.0
|
||||
if m_tot_min > m_tot:
|
||||
scale_factor = (m_tot_min / m_tot).magnitude
|
||||
m_titration_90 *= scale_factor
|
||||
m_titration_10 *= scale_factor
|
||||
m_solvent_add *= scale_factor
|
||||
m_solvent_titration *= scale_factor
|
||||
|
||||
# 更新二胺溶液用量
|
||||
for sol in diamine_solutions:
|
||||
sol["volume_needed"] *= scale_factor
|
||||
|
||||
# 更新固体二酐用量
|
||||
for anhydride in solid_anhydride_masses:
|
||||
anhydride["mass"] *= scale_factor
|
||||
|
||||
m_tot = m_tot_min
|
||||
|
||||
# 生成投料顺序
|
||||
feeding_order = []
|
||||
|
||||
# 1. 固体二酐 (按顺序)
|
||||
for anhydride in solid_anhydride_masses:
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "solid_anhydride",
|
||||
"name": anhydride["name"],
|
||||
"amount": anhydride["mass"],
|
||||
"order": anhydride["order"]
|
||||
})
|
||||
|
||||
# 2. 二胺溶液 (按顺序)
|
||||
for sol in diamine_solutions:
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "diamine_solution",
|
||||
"name": sol["name"],
|
||||
"amount": sol["volume_needed"],
|
||||
"order": sol["order"]
|
||||
})
|
||||
|
||||
# 3. 主要二酐粉末
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "main_anhydride",
|
||||
"name": titration_name,
|
||||
"amount": m_titration_90.magnitude,
|
||||
"order": titration_anhydride[3]
|
||||
})
|
||||
|
||||
# 4. 补加溶剂
|
||||
if m_solvent_add > 0:
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "additional_solvent",
|
||||
"name": "溶剂",
|
||||
"amount": m_solvent_add.magnitude,
|
||||
"order": 999
|
||||
})
|
||||
|
||||
# 5. 滴定二酐溶液
|
||||
feeding_order.append({
|
||||
"step": len(feeding_order) + 1,
|
||||
"type": "titration_anhydride",
|
||||
"name": f"{titration_name} 滴定液",
|
||||
"amount": m_titration_10.magnitude,
|
||||
"titration_solvent": m_solvent_titration.magnitude,
|
||||
"order": titration_anhydride[3]
|
||||
})
|
||||
|
||||
# 返回实验设计结果
|
||||
results = {
|
||||
"total_mass": m_tot.magnitude,
|
||||
"scale_factor": scale_factor,
|
||||
"solutions": diamine_solutions,
|
||||
"solids": solid_anhydride_masses,
|
||||
"titration": {
|
||||
"name": titration_name,
|
||||
"main_portion": m_titration_90.magnitude,
|
||||
"titration_portion": m_titration_10.magnitude,
|
||||
"titration_solvent": m_solvent_titration.magnitude,
|
||||
},
|
||||
"solvents": {
|
||||
"additional_solvent": m_solvent_add.magnitude,
|
||||
"total_liquid_volume": total_liquid_volume.magnitude
|
||||
},
|
||||
"feeding_order": feeding_order,
|
||||
"minimum_required_mass": m_tot_min.magnitude
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
# 90%10%小瓶投料任务创建方法
|
||||
def create_90_10_vial_feeding_task(self,
|
||||
order_name: str = None,
|
||||
@@ -1312,108 +961,6 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
'actualVolume': actual_volume
|
||||
}
|
||||
|
||||
def _simplify_report(self, report) -> Dict[str, Any]:
|
||||
"""简化实验报告,只保留关键信息,去除冗余的工作流参数"""
|
||||
if not isinstance(report, dict):
|
||||
return report
|
||||
|
||||
data = report.get('data', {})
|
||||
if not isinstance(data, dict):
|
||||
return report
|
||||
|
||||
# 提取关键信息
|
||||
simplified = {
|
||||
'name': data.get('name'),
|
||||
'code': data.get('code'),
|
||||
'requester': data.get('requester'),
|
||||
'workflowName': data.get('workflowName'),
|
||||
'workflowStep': data.get('workflowStep'),
|
||||
'requestTime': data.get('requestTime'),
|
||||
'startPreparationTime': data.get('startPreparationTime'),
|
||||
'completeTime': data.get('completeTime'),
|
||||
'useTime': data.get('useTime'),
|
||||
'status': data.get('status'),
|
||||
'statusName': data.get('statusName'),
|
||||
}
|
||||
|
||||
# 提取物料信息(简化版)
|
||||
pre_intakes = data.get('preIntakes', [])
|
||||
if pre_intakes and isinstance(pre_intakes, list):
|
||||
first_intake = pre_intakes[0]
|
||||
sample_materials = first_intake.get('sampleMaterials', [])
|
||||
|
||||
# 简化物料信息
|
||||
simplified_materials = []
|
||||
for material in sample_materials:
|
||||
if isinstance(material, dict):
|
||||
mat_info = {
|
||||
'materialName': material.get('materialName'),
|
||||
'materialTypeName': material.get('materialTypeName'),
|
||||
'materialCode': material.get('materialCode'),
|
||||
'materialLocation': material.get('materialLocation'),
|
||||
}
|
||||
|
||||
# 解析parameters中的关键信息(如密度、加料历史等)
|
||||
params_str = material.get('parameters', '{}')
|
||||
try:
|
||||
params = json.loads(params_str) if isinstance(params_str, str) else params_str
|
||||
if isinstance(params, dict):
|
||||
# 只保留关键参数
|
||||
if 'density' in params:
|
||||
mat_info['density'] = params['density']
|
||||
if 'feedingHistory' in params:
|
||||
mat_info['feedingHistory'] = params['feedingHistory']
|
||||
if 'liquidVolume' in params:
|
||||
mat_info['liquidVolume'] = params['liquidVolume']
|
||||
if 'm_diamine_tot' in params:
|
||||
mat_info['m_diamine_tot'] = params['m_diamine_tot']
|
||||
if 'wt_diamine' in params:
|
||||
mat_info['wt_diamine'] = params['wt_diamine']
|
||||
except:
|
||||
pass
|
||||
|
||||
simplified_materials.append(mat_info)
|
||||
|
||||
simplified['sampleMaterials'] = simplified_materials
|
||||
|
||||
# 提取extraProperties中的实际值
|
||||
extra_props = first_intake.get('extraProperties', {})
|
||||
if isinstance(extra_props, dict):
|
||||
simplified_extra = {}
|
||||
for key, value in extra_props.items():
|
||||
try:
|
||||
parsed_value = json.loads(value) if isinstance(value, str) else value
|
||||
simplified_extra[key] = parsed_value
|
||||
except:
|
||||
simplified_extra[key] = value
|
||||
simplified['extraProperties'] = simplified_extra
|
||||
|
||||
return {
|
||||
'data': simplified,
|
||||
'code': report.get('code'),
|
||||
'message': report.get('message'),
|
||||
'timestamp': report.get('timestamp')
|
||||
}
|
||||
|
||||
def scheduler_start(self) -> dict:
|
||||
"""启动调度器 - 启动Bioyond工作站的任务调度器,开始执行队列中的任务
|
||||
|
||||
Returns:
|
||||
dict: 包含return_info的字典,return_info为整型(1=成功)
|
||||
|
||||
Raises:
|
||||
BioyondException: 调度器启动失败时抛出异常
|
||||
"""
|
||||
result = self.hardware_interface.scheduler_start()
|
||||
self.hardware_interface._logger.info(f"调度器启动结果: {result}")
|
||||
|
||||
if result != 1:
|
||||
error_msg = "启动调度器失败: 有未处理错误,调度无法启动。请检查Bioyond系统状态。"
|
||||
self.hardware_interface._logger.error(error_msg)
|
||||
raise BioyondException(error_msg)
|
||||
|
||||
return {"return_info": result}
|
||||
|
||||
# 等待多个任务完成并获取实验报告
|
||||
def wait_for_multiple_orders_and_get_reports(self,
|
||||
batch_create_result: str = None,
|
||||
@@ -1455,12 +1002,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
|
||||
# 验证batch_create_result参数
|
||||
if not batch_create_result or batch_create_result == "":
|
||||
raise BioyondException(
|
||||
"batch_create_result参数为空,请确保:\n"
|
||||
"1. batch_create节点与wait节点之间正确连接了handle\n"
|
||||
"2. batch_create节点成功执行并返回了结果\n"
|
||||
"3. 检查上游batch_create任务是否成功创建了订单"
|
||||
)
|
||||
raise BioyondException("batch_create_result参数为空,请确保从batch_create节点正确连接handle")
|
||||
|
||||
# 解析batch_create_result JSON对象
|
||||
try:
|
||||
@@ -1489,17 +1031,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
|
||||
# 验证提取的数据
|
||||
if not order_codes:
|
||||
self.hardware_interface._logger.error(
|
||||
f"batch_create任务未生成任何订单。batch_create_result内容: {batch_create_result}"
|
||||
)
|
||||
raise BioyondException(
|
||||
"batch_create_result中未找到order_codes或为空。\n"
|
||||
"可能的原因:\n"
|
||||
"1. batch_create任务执行失败(检查任务是否报错)\n"
|
||||
"2. 物料配置问题(如'物料样品板分配失败')\n"
|
||||
"3. Bioyond系统状态异常\n"
|
||||
f"请检查batch_create任务的执行结果"
|
||||
)
|
||||
raise BioyondException("batch_create_result中未找到order_codes字段或为空")
|
||||
if not order_ids:
|
||||
raise BioyondException("batch_create_result中未找到order_ids字段或为空")
|
||||
|
||||
@@ -1582,8 +1114,6 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
self.hardware_interface._logger.info(
|
||||
f"成功获取任务 {order_code} 的实验报告"
|
||||
)
|
||||
# 简化报告,去除冗余信息
|
||||
report = self._simplify_report(report)
|
||||
|
||||
reports.append({
|
||||
"order_code": order_code,
|
||||
@@ -1758,7 +1288,7 @@ class BioyondDispensingStation(BioyondWorkstation):
|
||||
f"开始执行批量物料转移: {len(transfer_groups)}组任务 -> {target_device_id}"
|
||||
)
|
||||
|
||||
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
|
||||
from .config import WAREHOUSE_MAPPING
|
||||
results = []
|
||||
successful_count = 0
|
||||
failed_count = 0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@ Bioyond Workstation Implementation
|
||||
"""
|
||||
import time
|
||||
import traceback
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
import json
|
||||
@@ -24,94 +23,12 @@ from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
||||
from unilabos.ros.msgs.message_converter import convert_to_ros_msg, Float64, String
|
||||
from pylabrobot.resources.resource import Resource as ResourcePLR
|
||||
|
||||
|
||||
from unilabos.devices.workstation.bioyond_studio.config import (
|
||||
API_CONFIG, WORKFLOW_MAPPINGS, MATERIAL_TYPE_MAPPINGS, WAREHOUSE_MAPPING, HTTP_SERVICE_CONFIG
|
||||
)
|
||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
||||
|
||||
|
||||
class ConnectionMonitor:
|
||||
"""Bioyond连接监控器"""
|
||||
def __init__(self, workstation, check_interval=30):
|
||||
self.workstation = workstation
|
||||
self.check_interval = check_interval
|
||||
self._running = False
|
||||
self._thread = None
|
||||
self._last_status = "unknown"
|
||||
|
||||
def start(self):
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._monitor_loop, daemon=True, name="BioyondConnectionMonitor")
|
||||
self._thread.start()
|
||||
logger.info("Bioyond连接监控器已启动")
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2)
|
||||
logger.info("Bioyond连接监控器已停止")
|
||||
|
||||
def _monitor_loop(self):
|
||||
while self._running:
|
||||
try:
|
||||
# 使用 lightweight API 检查连接
|
||||
# query_matial_type_list 是比较快的查询
|
||||
start_time = time.time()
|
||||
result = self.workstation.hardware_interface.material_type_list()
|
||||
|
||||
status = "online" if result else "offline"
|
||||
msg = "Connection established" if status == "online" else "Failed to get material type list"
|
||||
|
||||
if status != self._last_status:
|
||||
logger.info(f"Bioyond连接状态变更: {self._last_status} -> {status}")
|
||||
self._publish_event(status, msg)
|
||||
self._last_status = status
|
||||
|
||||
# 发布心跳 (可选,或者只在状态变更时发布)
|
||||
# self._publish_event(status, msg)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Bioyond连接检查异常: {e}")
|
||||
if self._last_status != "error":
|
||||
self._publish_event("error", str(e))
|
||||
self._last_status = "error"
|
||||
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
def _publish_event(self, status, message):
|
||||
try:
|
||||
if hasattr(self.workstation, "_ros_node") and self.workstation._ros_node:
|
||||
event_data = {
|
||||
"status": status,
|
||||
"message": message,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# 动态发布消息,需要在 ROS2DeviceNode 中有对应支持
|
||||
# 这里假设通用事件发布机制,使用 String 类型的 topic
|
||||
# 话题: /<namespace>/events/device_status
|
||||
ns = self.workstation._ros_node.namespace
|
||||
topic = f"{ns}/events/device_status"
|
||||
|
||||
# 使用 ROS2DeviceNode 的发布功能
|
||||
# 如果没有预定义的 publisher,需要动态创建
|
||||
# 注意:workstation base node 可能没有自动创建 arbitrary publishers 的机制
|
||||
# 这里我们先尝试用 String json 发布
|
||||
|
||||
# 在 ROS2DeviceNode 中通常需要先 create_publisher
|
||||
# 为了简单起见,我们检查是否已有 publisher,没有则创建
|
||||
if not hasattr(self.workstation, "_device_status_pub"):
|
||||
self.workstation._device_status_pub = self.workstation._ros_node.create_publisher(
|
||||
String, topic, 10
|
||||
)
|
||||
|
||||
self.workstation._device_status_pub.publish(
|
||||
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"发布设备状态事件失败: {e}")
|
||||
|
||||
|
||||
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
"""Bioyond资源同步器
|
||||
|
||||
@@ -257,8 +174,9 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
else:
|
||||
logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库")
|
||||
|
||||
# 第1步:从配置中获取仓库配置
|
||||
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
|
||||
# 第1步:获取仓库配置
|
||||
from .config import WAREHOUSE_MAPPING
|
||||
warehouse_mapping = WAREHOUSE_MAPPING
|
||||
|
||||
# 确定目标仓库名称
|
||||
parent_name = None
|
||||
@@ -320,20 +238,14 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
# 第2步:转换为 Bioyond 格式
|
||||
logger.info(f"[同步→Bioyond] 🔄 转换物料为 Bioyond 格式...")
|
||||
|
||||
# 从配置中获取物料默认参数
|
||||
material_default_params = self.workstation.bioyond_config.get("material_default_parameters", {})
|
||||
material_type_params = self.workstation.bioyond_config.get("material_type_parameters", {})
|
||||
|
||||
# 合并参数配置:物料名称参数 + typeId参数(转换为 type:<uuid> 格式)
|
||||
merged_params = material_default_params.copy()
|
||||
for type_id, params in material_type_params.items():
|
||||
merged_params[f"type:{type_id}"] = params
|
||||
# 导入物料默认参数配置
|
||||
from .config import MATERIAL_DEFAULT_PARAMETERS
|
||||
|
||||
bioyond_material = resource_plr_to_bioyond(
|
||||
[resource],
|
||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
||||
material_params=merged_params
|
||||
material_params=MATERIAL_DEFAULT_PARAMETERS
|
||||
)[0]
|
||||
|
||||
logger.info(f"[同步→Bioyond] 🔧 准备覆盖locations字段,目标仓库: {parent_name}, 库位: {update_site}, UUID: {target_location_uuid[:8]}...")
|
||||
@@ -556,20 +468,13 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
return material_bioyond_id
|
||||
|
||||
# 转换为 Bioyond 格式
|
||||
# 从配置中获取物料默认参数
|
||||
material_default_params = self.workstation.bioyond_config.get("material_default_parameters", {})
|
||||
material_type_params = self.workstation.bioyond_config.get("material_type_parameters", {})
|
||||
|
||||
# 合并参数配置:物料名称参数 + typeId参数(转换为 type:<uuid> 格式)
|
||||
merged_params = material_default_params.copy()
|
||||
for type_id, params in material_type_params.items():
|
||||
merged_params[f"type:{type_id}"] = params
|
||||
from .config import MATERIAL_DEFAULT_PARAMETERS
|
||||
|
||||
bioyond_material = resource_plr_to_bioyond(
|
||||
[resource],
|
||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
||||
warehouse_mapping=self.workstation.bioyond_config["warehouse_mapping"],
|
||||
material_params=merged_params
|
||||
material_params=MATERIAL_DEFAULT_PARAMETERS
|
||||
)[0]
|
||||
|
||||
# ⚠️ 关键:创建物料时不设置 locations,让 Bioyond 系统暂不分配库位
|
||||
@@ -623,7 +528,8 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
logger.info(f"[物料入库] 目标库位: {update_site}")
|
||||
|
||||
# 获取仓库配置和目标库位 UUID
|
||||
warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {})
|
||||
from .config import WAREHOUSE_MAPPING
|
||||
warehouse_mapping = WAREHOUSE_MAPPING
|
||||
|
||||
parent_name = None
|
||||
target_location_uuid = None
|
||||
@@ -678,44 +584,6 @@ class BioyondWorkstation(WorkstationBase):
|
||||
集成Bioyond物料管理的工作站实现
|
||||
"""
|
||||
|
||||
def _publish_task_status(
|
||||
self,
|
||||
task_id: str,
|
||||
task_type: str,
|
||||
status: str,
|
||||
result: dict = None,
|
||||
progress: float = 0.0,
|
||||
task_code: str = None
|
||||
):
|
||||
"""发布任务状态事件"""
|
||||
try:
|
||||
if not getattr(self, "_ros_node", None):
|
||||
return
|
||||
|
||||
event_data = {
|
||||
"task_id": task_id,
|
||||
"task_code": task_code,
|
||||
"task_type": task_type,
|
||||
"status": status,
|
||||
"progress": progress,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
if result:
|
||||
event_data["result"] = result
|
||||
|
||||
topic = f"{self._ros_node.namespace}/events/task_status"
|
||||
|
||||
if not hasattr(self, "_task_status_pub"):
|
||||
self._task_status_pub = self._ros_node.create_publisher(
|
||||
String, topic, 10
|
||||
)
|
||||
|
||||
self._task_status_pub.publish(
|
||||
convert_to_ros_msg(String, json.dumps(event_data, ensure_ascii=False))
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"发布任务状态事件失败: {e}")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bioyond_config: Optional[Dict[str, Any]] = None,
|
||||
@@ -737,28 +605,10 @@ class BioyondWorkstation(WorkstationBase):
|
||||
raise ValueError("Deck 配置不能为空,请在配置文件中添加正确的 deck 配置")
|
||||
|
||||
# 初始化 warehouses 属性
|
||||
if not hasattr(self.deck, "warehouses") or self.deck.warehouses is None:
|
||||
self.deck.warehouses = {}
|
||||
|
||||
# 仅当 warehouses 为空时尝试重新扫描(避免覆盖子类的修复)
|
||||
if not self.deck.warehouses:
|
||||
for resource in self.deck.children:
|
||||
# 兼容性增强: 只要是仓库类别或者是 WareHouse 实例均可
|
||||
is_warehouse = isinstance(resource, WareHouse) or getattr(resource, "category", "") == "warehouse"
|
||||
|
||||
# 如果配置中有定义,也可以认定为 warehouse
|
||||
if not is_warehouse and "warehouse_mapping" in bioyond_config:
|
||||
if resource.name in bioyond_config["warehouse_mapping"]:
|
||||
is_warehouse = True
|
||||
|
||||
if is_warehouse:
|
||||
self.deck.warehouses[resource.name] = resource
|
||||
# 确保 category 被正确设置,方便后续使用
|
||||
if getattr(resource, "category", "") != "warehouse":
|
||||
try:
|
||||
resource.category = "warehouse"
|
||||
except:
|
||||
pass
|
||||
self.deck.warehouses = {}
|
||||
for resource in self.deck.children:
|
||||
if isinstance(resource, WareHouse):
|
||||
self.deck.warehouses[resource.name] = resource
|
||||
|
||||
# 创建通信模块
|
||||
self._create_communication_module(bioyond_config)
|
||||
@@ -777,22 +627,18 @@ class BioyondWorkstation(WorkstationBase):
|
||||
self._set_workflow_mappings(bioyond_config["workflow_mappings"])
|
||||
|
||||
# 准备 HTTP 报送接收服务配置(延迟到 post_init 启动)
|
||||
# 从 bioyond_config 中的 http_service_config 获取
|
||||
http_service_cfg = bioyond_config.get("http_service_config", {})
|
||||
# 从 bioyond_config 中获取,如果没有则使用 HTTP_SERVICE_CONFIG 的默认值
|
||||
self._http_service_config = {
|
||||
"host": http_service_cfg.get("http_service_host", "127.0.0.1"),
|
||||
"port": http_service_cfg.get("http_service_port", 8080)
|
||||
"host": bioyond_config.get("http_service_host", HTTP_SERVICE_CONFIG["http_service_host"]),
|
||||
"port": bioyond_config.get("http_service_port", HTTP_SERVICE_CONFIG["http_service_port"])
|
||||
}
|
||||
self.http_service = None # 将在 post_init 启动
|
||||
self.connection_monitor = None # 将在 post_init 启动
|
||||
self.http_service = None # 将在 post_init 中启动
|
||||
|
||||
logger.info(f"Bioyond工作站初始化完成")
|
||||
|
||||
def __del__(self):
|
||||
"""析构函数:清理资源,停止 HTTP 服务"""
|
||||
try:
|
||||
if hasattr(self, 'connection_monitor') and self.connection_monitor:
|
||||
self.connection_monitor.stop()
|
||||
if hasattr(self, 'http_service') and self.http_service is not None:
|
||||
logger.info("正在停止 HTTP 报送服务...")
|
||||
self.http_service.stop()
|
||||
@@ -802,19 +648,8 @@ class BioyondWorkstation(WorkstationBase):
|
||||
def post_init(self, ros_node: ROS2WorkstationNode):
|
||||
self._ros_node = ros_node
|
||||
|
||||
# 启动连接监控
|
||||
try:
|
||||
self.connection_monitor = ConnectionMonitor(self)
|
||||
self.connection_monitor.start()
|
||||
except Exception as e:
|
||||
logger.error(f"启动连接监控失败: {e}")
|
||||
|
||||
# 启动 HTTP 报送接收服务(现在 device_id 已可用)
|
||||
# ⚠️ 检查子类是否已经自己管理 HTTP 服务
|
||||
if self.bioyond_config.get("_disable_auto_http_service"):
|
||||
logger.info("🔧 检测到 _disable_auto_http_service 标志,跳过自动启动 HTTP 服务")
|
||||
logger.info(" 子类(BioyondCellWorkstation)已自行管理 HTTP 服务")
|
||||
elif hasattr(self, '_http_service_config'):
|
||||
if hasattr(self, '_http_service_config'):
|
||||
try:
|
||||
self.http_service = WorkstationHTTPService(
|
||||
workstation_instance=self,
|
||||
@@ -853,14 +688,19 @@ class BioyondWorkstation(WorkstationBase):
|
||||
|
||||
def _create_communication_module(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""创建Bioyond通信模块"""
|
||||
# 直接使用传入的配置,不再使用默认值
|
||||
# 所有配置必须从 JSON 文件中提供
|
||||
# 创建默认配置
|
||||
default_config = {
|
||||
**API_CONFIG,
|
||||
"workflow_mappings": WORKFLOW_MAPPINGS,
|
||||
"material_type_mappings": MATERIAL_TYPE_MAPPINGS,
|
||||
"warehouse_mapping": WAREHOUSE_MAPPING
|
||||
}
|
||||
|
||||
# 如果传入了 config,合并配置(config 中的值会覆盖默认值)
|
||||
if config:
|
||||
self.bioyond_config = config
|
||||
self.bioyond_config = {**default_config, **config}
|
||||
else:
|
||||
# 如果没有配置,使用空字典(会导致后续错误,但这是预期的)
|
||||
self.bioyond_config = {}
|
||||
print("警告: 未提供 bioyond_config,请确保在 JSON 配置文件中提供完整配置")
|
||||
self.bioyond_config = default_config
|
||||
|
||||
self.hardware_interface = BioyondV1RPC(self.bioyond_config)
|
||||
|
||||
@@ -1174,15 +1014,7 @@ class BioyondWorkstation(WorkstationBase):
|
||||
|
||||
workflow_id = self._get_workflow(actual_workflow_name)
|
||||
if workflow_id:
|
||||
# 兼容 BioyondReactionStation 中 workflow_sequence 被重写为 property 的情况
|
||||
if isinstance(self.workflow_sequence, list):
|
||||
self.workflow_sequence.append(workflow_id)
|
||||
elif hasattr(self, "_cached_workflow_sequence") and isinstance(self._cached_workflow_sequence, list):
|
||||
self._cached_workflow_sequence.append(workflow_id)
|
||||
else:
|
||||
print(f"❌ 无法添加工作流: workflow_sequence 类型错误 {type(self.workflow_sequence)}")
|
||||
return False
|
||||
|
||||
self.workflow_sequence.append(workflow_id)
|
||||
print(f"添加工作流到执行顺序: {actual_workflow_name} -> {workflow_id}")
|
||||
return True
|
||||
return False
|
||||
@@ -1383,22 +1215,6 @@ class BioyondWorkstation(WorkstationBase):
|
||||
# TODO: 根据实际业务需求处理步骤完成逻辑
|
||||
# 例如:更新数据库、触发后续流程等
|
||||
|
||||
# 发布任务状态事件 (running/progress update)
|
||||
self._publish_task_status(
|
||||
task_id=data.get('orderCode'), # 使用 OrderCode 作为关联 ID
|
||||
task_code=data.get('orderCode'),
|
||||
task_type="bioyond_step",
|
||||
status="running",
|
||||
progress=0.5, # 步骤完成视为任务进行中
|
||||
result={"step_name": data.get('stepName'), "step_id": data.get('stepId')}
|
||||
)
|
||||
|
||||
# 更新物料信息
|
||||
# 步骤完成后,物料状态可能发生变化(如位置、用量等),触发同步
|
||||
logger.info(f"[步骤完成报送] 触发物料同步...")
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
|
||||
|
||||
return {
|
||||
"processed": True,
|
||||
"step_id": data.get('stepId'),
|
||||
@@ -1433,17 +1249,6 @@ class BioyondWorkstation(WorkstationBase):
|
||||
|
||||
# TODO: 根据实际业务需求处理通量完成逻辑
|
||||
|
||||
# 发布任务状态事件
|
||||
self._publish_task_status(
|
||||
task_id=data.get('orderCode'),
|
||||
task_code=data.get('orderCode'),
|
||||
task_type="bioyond_sample",
|
||||
status="running",
|
||||
progress=0.7,
|
||||
result={"sample_id": data.get('sampleId'), "status": status_desc}
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
"processed": True,
|
||||
"sample_id": data.get('sampleId'),
|
||||
@@ -1483,32 +1288,6 @@ class BioyondWorkstation(WorkstationBase):
|
||||
# TODO: 根据实际业务需求处理任务完成逻辑
|
||||
# 例如:更新物料库存、生成报表等
|
||||
|
||||
# 映射状态到事件状态
|
||||
event_status = "completed"
|
||||
if str(data.get('status')) in ["-11", "-12"]:
|
||||
event_status = "error"
|
||||
elif str(data.get('status')) == "30":
|
||||
event_status = "completed"
|
||||
else:
|
||||
event_status = "running" # 其他状态视为运行中(或根据实际定义)
|
||||
|
||||
# 发布任务状态事件
|
||||
self._publish_task_status(
|
||||
task_id=data.get('orderCode'),
|
||||
task_code=data.get('orderCode'),
|
||||
task_type="bioyond_order",
|
||||
status=event_status,
|
||||
progress=1.0 if event_status in ["completed", "error"] else 0.9,
|
||||
result={"order_name": data.get('orderName'), "status": status_desc, "materials_count": len(used_materials)}
|
||||
)
|
||||
|
||||
# 更新物料信息
|
||||
# 任务完成后,且状态为完成时,触发同步以更新最终物料状态
|
||||
if event_status == "completed":
|
||||
logger.info(f"[任务完成报送] 触发物料同步...")
|
||||
self.resource_synchronizer.sync_from_external()
|
||||
|
||||
|
||||
return {
|
||||
"processed": True,
|
||||
"order_code": data.get('orderCode'),
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
# Modbus CSV 地址映射说明
|
||||
|
||||
本文档说明 `coin_cell_assembly_a.csv` 文件如何将命名节点映射到实际的 Modbus 地址,以及如何在代码中使用它们。
|
||||
|
||||
## 1. CSV 文件结构
|
||||
|
||||
地址表文件位于同级目录下:`coin_cell_assembly_a.csv`
|
||||
|
||||
每一行定义了一个 Modbus 节点,包含以下关键列:
|
||||
|
||||
| 列名 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| **Name** | **节点名称** (代码中引用的 Key) | `COIL_ALUMINUM_FOIL` |
|
||||
| **DataType** | 数据类型 (BOOL, INT16, FLOAT32, STRING) | `BOOL` |
|
||||
| **Comment** | 注释说明 | `使用铝箔垫` |
|
||||
| **Attribute** | 属性 (通常留空或用于额外标记) | |
|
||||
| **DeviceType** | Modbus 寄存器类型 (`coil`, `hold_register`) | `coil` |
|
||||
| **Address** | **Modbus 地址** (十进制) | `8340` |
|
||||
|
||||
### 示例行 (铝箔垫片)
|
||||
|
||||
```csv
|
||||
COIL_ALUMINUM_FOIL,BOOL,,使用铝箔垫,,coil,8340,
|
||||
```
|
||||
|
||||
- **名称**: `COIL_ALUMINUM_FOIL`
|
||||
- **类型**: `coil` (线圈,读写单个位)
|
||||
- **地址**: `8340`
|
||||
|
||||
---
|
||||
|
||||
## 2. 加载与注册流程
|
||||
|
||||
在 `coin_cell_assembly.py` 的初始化代码中:
|
||||
|
||||
1. **加载 CSV**: `BaseClient.load_csv()` 读取 CSV 并解析每行定义。
|
||||
2. **注册节点**: `modbus_client.register_node_list()` 将解析后的节点注册到 Modbus 客户端实例中。
|
||||
|
||||
```python
|
||||
# 代码位置: coin_cell_assembly.py (L174-175)
|
||||
self.nodes = BaseClient.load_csv(os.path.join(os.path.dirname(__file__), 'coin_cell_assembly_a.csv'))
|
||||
self.client = modbus_client.register_node_list(self.nodes)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 代码中的使用方式
|
||||
|
||||
注册后,通过 `self.client.use_node('节点名称')` 即可获取该节点对象并进行读写操作,无需关心具体地址。
|
||||
|
||||
### 控制铝箔垫片 (COIL_ALUMINUM_FOIL)
|
||||
|
||||
```python
|
||||
# 代码位置: qiming_coin_cell_code 函数 (L1048)
|
||||
self.client.use_node('COIL_ALUMINUM_FOIL').write(not lvbodian)
|
||||
```
|
||||
|
||||
- **写入 True**: 对应 Modbus 功能码 05 (Write Single Coil),向地址 `8340` 写入 `1` (ON)。
|
||||
- **写入 False**: 向地址 `8340` 写入 `0` (OFF)。
|
||||
|
||||
> **注意**: 代码中使用了 `not lvbodian`,这意味着逻辑是反转的。如果 `lvbodian` 参数为 `True` (默认),写入的是 `False` (不使用铝箔垫)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 地址转换注意事项 (Modbus vs PLC)
|
||||
|
||||
CSV 中的 `Address` 列(如 `8340`)是 **Modbus 协议地址**。
|
||||
|
||||
如果使用 InoProShop (汇川 PLC 编程软件),看到的可能是 **PLC 内部地址** (如 `%QX...` 或 `%MW...`)。这两者之间通常需要转换。
|
||||
|
||||
### 常见的转换规则 (示例)
|
||||
|
||||
- **Coil (线圈) %QX**:
|
||||
- `Modbus地址 = 字节地址 * 8 + 位偏移`
|
||||
- *例子*: `%QX834.0` -> `834 * 8 + 0` = `6672`
|
||||
- *注意*: 如果 CSV 中配置的是 `8340`,这可能是一个自定义映射,或者是基于不同规则(如直接对应 Word 地址的某种映射,或者可能就是地址写错了/使用了非标准映射)。
|
||||
|
||||
- **Register (寄存器) %MW**:
|
||||
- 通常直接对应,或者有偏移量 (如 Modbus 40001 = PLC MW0)。
|
||||
|
||||
### 验证方法
|
||||
由于 `test_unilab_interact.py` 中发现 `8450` (CSV风格) 不工作,而 `6760` (%QX845.0 计算值) 工作正常,**建议对 CSV 中的其他地址也进行核实**,特别是像 `8340` 这样以 0 结尾看起来像是 "字节地址+0" 的数值,可能实际上应该是 `%QX834.0` 对应的 `6672`。
|
||||
|
||||
如果发现设备控制无反应,请尝试按照标准的 Modbus 计算方式转换 PLC 地址。
|
||||
@@ -1,352 +0,0 @@
|
||||
# 2026-01-13 物料搜寻确认弹窗自动处理功能
|
||||
|
||||
## 概述
|
||||
|
||||
本次更新为设备初始化流程添加了**物料搜寻确认弹窗自动检测与处理功能**。在设备初始化过程中,PLC 会弹出物料搜寻确认对话框,现在系统可以根据用户参数自动点击"是"或"否"按钮,无需手动干预。
|
||||
|
||||
## 背景问题
|
||||
|
||||
### 原有流程
|
||||
1. 调用 `func_pack_device_init_auto_start_combined()` 初始化设备
|
||||
2. PLC 在初始化过程中弹出物料搜寻确认对话框
|
||||
3. **需要人工手动点击**"是"或"否"按钮
|
||||
4. PLC 继续完成初始化并启动
|
||||
|
||||
### 存在的问题
|
||||
- 需要人工干预,无法实现全自动化
|
||||
- 影响批量生产效率
|
||||
- 容易遗忘点击导致流程卡住
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 新增 Modbus 地址配置
|
||||
|
||||
在 `coin_cell_assembly_b.csv` 第 69-71 行添加三个 coil:
|
||||
|
||||
| Name | DeviceType | Address | 说明 |
|
||||
|------|-----------|---------|------|
|
||||
| COIL_MATERIAL_SEARCH_DIALOG_APPEAR | coil | 6470 | 物料搜寻确认弹窗画面是否出现 |
|
||||
| COIL_MATERIAL_SEARCH_CONFIRM_YES | coil | 6480 | 初始化物料搜寻确认按钮"是" |
|
||||
| COIL_MATERIAL_SEARCH_CONFIRM_NO | coil | 6490 | 初始化物料搜寻确认按钮"否" |
|
||||
|
||||
**Modbus 地址转换:**
|
||||
- CSV 6470 → Modbus 5176 (弹窗出现)
|
||||
- CSV 6480 → Modbus 5184 (按钮"是")
|
||||
- CSV 6490 → Modbus 5192 (按钮"否")
|
||||
|
||||
## 代码修改详情
|
||||
|
||||
### 1. coin_cell_assembly.py
|
||||
|
||||
#### 1.1 新增辅助方法 `_handle_material_search_dialog()`
|
||||
|
||||
**位置:** 第 799-901 行
|
||||
|
||||
**功能:**
|
||||
- 监测物料搜寻确认弹窗是否出现(Coil 5176)
|
||||
- 根据 `enable_search` 参数自动点击对应按钮
|
||||
- 使用**脉冲模式**模拟真实按钮操作:`True` → 保持 0.5 秒 → `False`
|
||||
|
||||
**参数:**
|
||||
- `enable_search: bool` - True=点击"是"(启用物料搜寻), False=点击"否"(不启用)
|
||||
- `timeout: int = 30` - 等待弹窗出现的最大时间(秒)
|
||||
|
||||
**逻辑流程:**
|
||||
```python
|
||||
1. 监测 COIL_MATERIAL_SEARCH_DIALOG_APPEAR (每 0.5 秒检查一次)
|
||||
2. 检测到弹窗出现 (Coil = True)
|
||||
3. 选择按钮:
|
||||
- enable_search=True → COIL_MATERIAL_SEARCH_CONFIRM_YES
|
||||
- enable_search=False → COIL_MATERIAL_SEARCH_CONFIRM_NO
|
||||
4. 执行脉冲操作:
|
||||
- 写入 True (按下按钮)
|
||||
- 等待 0.5 秒
|
||||
- 写入 False (释放按钮)
|
||||
- 验证状态
|
||||
```
|
||||
|
||||
#### 1.2 修改 `func_pack_device_init_auto_start_combined()`
|
||||
|
||||
**位置:** 第 904-1115 行
|
||||
|
||||
**主要改动:**
|
||||
|
||||
1. **添加新参数**
|
||||
```python
|
||||
def func_pack_device_init_auto_start_combined(
|
||||
self,
|
||||
material_search_enable: bool = False # 新增参数
|
||||
) -> bool:
|
||||
```
|
||||
|
||||
2. **内联初始化逻辑并集成弹窗检测**
|
||||
- 不再调用 `self.func_pack_device_init()`
|
||||
- 将初始化逻辑直接实现在函数内
|
||||
- **在等待初始化完成的循环中实时检测弹窗**
|
||||
- 避免死锁:PLC 等待弹窗确认 ↔ 代码等待初始化完成
|
||||
|
||||
3. **关键代码片段**
|
||||
```python
|
||||
# 等待初始化完成,同时检测物料搜寻弹窗
|
||||
while (self._sys_init_status()) == False:
|
||||
# 检查超时
|
||||
if time.time() - start_wait > max_wait_time:
|
||||
raise RuntimeError(f"初始化超时")
|
||||
|
||||
# 如果还没处理弹窗,检测弹窗是否出现
|
||||
if not dialog_handled:
|
||||
dialog_state = self.client.use_node('COIL_MATERIAL_SEARCH_DIALOG_APPEAR').read(1)
|
||||
if dialog_actual: # 弹窗出现
|
||||
# 执行脉冲按钮点击
|
||||
button_node.write(True) # 按下
|
||||
time.sleep(0.5) # 保持
|
||||
button_node.write(False) # 释放
|
||||
dialog_handled = True
|
||||
|
||||
time.sleep(1)
|
||||
```
|
||||
|
||||
4. **步骤调整**
|
||||
- 步骤 0: 前置条件检查
|
||||
- 步骤 1: 设备初始化(**包含弹窗检测**)
|
||||
- 步骤 1.5: 已在步骤 1 中完成
|
||||
- 步骤 2: 切换自动模式
|
||||
- 步骤 3: 启动设备
|
||||
|
||||
### 2. coin_cell_workstation.yaml
|
||||
|
||||
**位置:** 第 292-312 行
|
||||
|
||||
**修改内容:**
|
||||
|
||||
```yaml
|
||||
auto-func_pack_device_init_auto_start_combined:
|
||||
goal_default:
|
||||
material_search_enable: false # 新增默认值
|
||||
|
||||
schema:
|
||||
description: 组合函数:设备初始化 + 物料搜寻确认 + 切换自动模式 + 启动。初始化过程中会自动检测物料搜寻确认弹窗,并根据参数自动点击"是"或"否"按钮
|
||||
|
||||
goal:
|
||||
properties:
|
||||
material_search_enable: # 新增参数配置
|
||||
default: false
|
||||
description: 是否启用物料搜寻功能。设备初始化后会弹出物料搜寻确认弹窗,此参数控制自动点击"是"(启用)或"否"(不启用)。默认为false(不启用物料搜寻)
|
||||
type: boolean
|
||||
```
|
||||
|
||||
### 3. 测试脚本(已创建,用户已删除)
|
||||
|
||||
#### 3.1 test_material_search_dialog.py
|
||||
- 从 CSV 动态加载 Modbus 地址
|
||||
- 支持 4 种测试模式:
|
||||
- `query` - 查询所有状态
|
||||
- `dialog <0|1>` - 设置弹窗出现/消失
|
||||
- `yes` - 脉冲点击"是"按钮
|
||||
- `no` - 脉冲点击"否"按钮
|
||||
- 兼容 pymodbus 3.x API
|
||||
|
||||
#### 3.2 更新其他测试脚本
|
||||
- `test_coin_cell_reset.py` - 更新为 pymodbus 3.x API
|
||||
- `test_unilab_interact.py` - 更新为 pymodbus 3.x API
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 参数说明
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `material_search_enable` | boolean | `false` | 是否启用物料搜寻功能 |
|
||||
|
||||
### 调用示例
|
||||
|
||||
#### 1. 不启用物料搜寻(默认)
|
||||
```python
|
||||
# 默认参数,点击"否"按钮
|
||||
await device.func_pack_device_init_auto_start_combined()
|
||||
```
|
||||
|
||||
或在 YAML workflow 中:
|
||||
```yaml
|
||||
# 使用默认值 false,不启用物料搜寻
|
||||
- BatteryStation/auto-func_pack_device_init_auto_start_combined: {}
|
||||
```
|
||||
|
||||
#### 2. 启用物料搜寻
|
||||
```python
|
||||
# 显式设置为 True,点击"是"按钮
|
||||
await device.func_pack_device_init_auto_start_combined(
|
||||
material_search_enable=True
|
||||
)
|
||||
```
|
||||
|
||||
或在 YAML workflow 中:
|
||||
```yaml
|
||||
- BatteryStation/auto-func_pack_device_init_auto_start_combined:
|
||||
goal:
|
||||
material_search_enable: true # 启用物料搜寻
|
||||
```
|
||||
|
||||
## 执行日志示例
|
||||
|
||||
```
|
||||
26-01-13 [21:32:44] [INFO] 开始组合操作:设备初始化 → 物料搜寻确认 → 自动模式 → 启动
|
||||
26-01-13 [21:32:44] [INFO] 【步骤 0/4】前置条件检查...
|
||||
26-01-13 [21:32:44] [INFO] ✓ REG_UNILAB_INTERACT 检查通过
|
||||
26-01-13 [21:32:44] [INFO] ✓ COIL_GB_L_IGNORE_CMD 检查通过
|
||||
26-01-13 [21:32:44] [INFO] 【步骤 1/4】设备初始化...
|
||||
26-01-13 [21:32:44] [INFO] 切换手动模式...
|
||||
26-01-13 [21:32:46] [INFO] 发送初始化命令...
|
||||
26-01-13 [21:32:47] [INFO] 等待初始化完成(同时监测物料搜寻弹窗)...
|
||||
26-01-13 [21:33:05] [INFO] ✓ 在初始化过程中检测到物料搜寻确认弹窗!
|
||||
26-01-13 [21:33:05] [INFO] 用户选择: 不启用物料搜寻(点击否)
|
||||
26-01-13 [21:33:05] [INFO] → 按下按钮 '否'
|
||||
26-01-13 [21:33:06] [INFO] → 释放按钮 '否'
|
||||
26-01-13 [21:33:07] [INFO] ✓ 成功处理物料搜寻确认弹窗(选择: 否)
|
||||
26-01-13 [21:33:08] [INFO] ✓ 初始化状态完成
|
||||
26-01-13 [21:33:12] [INFO] ✓ 设备初始化完成
|
||||
26-01-13 [21:33:12] [INFO] 【步骤 1.5/4】物料搜寻确认已在初始化过程中完成
|
||||
26-01-13 [21:33:12] [INFO] 【步骤 2/4】切换自动模式...
|
||||
26-01-13 [21:33:15] [INFO] ✓ 切换自动模式完成
|
||||
26-01-13 [21:33:15] [INFO] 【步骤 3/4】启动设备...
|
||||
26-01-13 [21:33:18] [INFO] ✓ 启动设备完成
|
||||
26-01-13 [21:33:18] [INFO] 组合操作完成:设备已成功初始化、确认物料搜寻、切换自动模式并启动
|
||||
```
|
||||
|
||||
## 技术要点
|
||||
|
||||
### 1. 脉冲模式按钮操作
|
||||
模拟真实按钮按压过程:
|
||||
1. 写入 `True` (按下)
|
||||
2. 保持 0.5 秒
|
||||
3. 写入 `False` (释放)
|
||||
4. 验证状态
|
||||
|
||||
### 2. 避免死锁
|
||||
**问题:** PLC 在初始化过程中等待弹窗确认,而代码等待初始化完成
|
||||
**解决:** 在初始化等待循环中实时检测弹窗,一旦出现立即处理
|
||||
|
||||
### 3. 超时保护
|
||||
- 弹窗检测超时:30 秒(在 `_handle_material_search_dialog` 中)
|
||||
- 初始化超时:120 秒(在 `func_pack_device_init_auto_start_combined` 中)
|
||||
|
||||
### 4. PyModbus 3.x API 兼容
|
||||
所有 Modbus 操作使用 keyword arguments:
|
||||
```python
|
||||
# 读取
|
||||
client.read_coils(address=5176, count=1)
|
||||
|
||||
# 写入
|
||||
client.write_coil(address=5184, value=True)
|
||||
```
|
||||
|
||||
## 向后兼容性
|
||||
|
||||
### 保留的原有函数
|
||||
- `func_pack_device_init()` - 单独的初始化函数,不包含弹窗处理
|
||||
- 仍可在 YAML 中通过 `auto-func_pack_device_init` 调用
|
||||
- 用于不需要自动处理弹窗的场景
|
||||
|
||||
### 新增的功能
|
||||
- 在 `func_pack_device_init_auto_start_combined()` 中集成弹窗处理
|
||||
- 通过参数控制,默认行为与之前兼容(点击"否")
|
||||
|
||||
## 验证测试
|
||||
|
||||
### 测试场景
|
||||
|
||||
#### 场景 1:默认参数(不启用物料搜寻)
|
||||
```bash
|
||||
# 调用时不传参数
|
||||
BatteryStation/auto-func_pack_device_init_auto_start_combined: {}
|
||||
```
|
||||
**预期结果:**
|
||||
- ✅ 检测到弹窗
|
||||
- ✅ 自动点击"否"按钮
|
||||
- ✅ 初始化完成并启动成功
|
||||
|
||||
#### 场景 2:启用物料搜寻
|
||||
```bash
|
||||
# 设置 material_search_enable=true
|
||||
BatteryStation/auto-func_pack_device_init_auto_start_combined:
|
||||
goal:
|
||||
material_search_enable: true
|
||||
```
|
||||
**预期结果:**
|
||||
- ✅ 检测到弹窗
|
||||
- ✅ 自动点击"是"按钮
|
||||
- ✅ 初始化完成并启动成功
|
||||
|
||||
### 实际测试结果
|
||||
|
||||
**测试时间:** 2026-01-13 21:32:43
|
||||
**测试参数:** `material_search_enable: false`
|
||||
**测试结果:** ✅ 成功
|
||||
|
||||
**关键时间节点:**
|
||||
- 21:33:05 - 检测到弹窗
|
||||
- 21:33:05 - 按下"否"按钮
|
||||
- 21:33:06 - 释放"否"按钮
|
||||
- 21:33:07 - 弹窗处理完成
|
||||
- 21:33:08 - 初始化状态完成
|
||||
- 21:33:18 - 整个流程完成
|
||||
|
||||
**总耗时:** 约 35 秒(包含初始化全过程)
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **CSV 配置依赖**
|
||||
- 确保 `coin_cell_assembly_b.csv` 包含 69-71 行的 coil 配置
|
||||
- 地址转换逻辑:`modbus_addr = (csv_addr // 10) * 8 + (csv_addr % 10)`
|
||||
|
||||
2. **默认行为**
|
||||
- 默认 `material_search_enable=false`,即不启用物料搜寻
|
||||
- 如需启用,必须显式设置为 `true`
|
||||
|
||||
3. **日志级别**
|
||||
- 弹窗检测过程中的 `waiting for init_cmd` 使用 DEBUG 级别
|
||||
- 关键操作(检测到弹窗、按钮操作)使用 INFO 级别
|
||||
|
||||
4. **原有函数保留**
|
||||
- `func_pack_device_init()` 仍然可用,但不包含弹窗处理
|
||||
- 如果单独调用此函数,仍需手动处理弹窗
|
||||
|
||||
## 文件清单
|
||||
|
||||
### 修改的文件
|
||||
1. `d:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly.py`
|
||||
- 新增 `_handle_material_search_dialog()` 方法
|
||||
- 修改 `func_pack_device_init_auto_start_combined()` 函数
|
||||
|
||||
2. `d:\UniLabdev\Uni-Lab-OS\unilabos\registry\devices\coin_cell_workstation.yaml`
|
||||
- 更新 `auto-func_pack_device_init_auto_start_combined` 配置
|
||||
- 添加 `material_search_enable` 参数说明
|
||||
|
||||
3. `d:\UniLabdev\Uni-Lab-OS\unilabos\devices\workstation\coin_cell_assembly\coin_cell_assembly_b.csv`
|
||||
- 第 69-71 行添加三个 coil 配置
|
||||
|
||||
### 创建的测试文件(已删除)
|
||||
1. `test_material_search_dialog.py` - 物料搜寻弹窗测试脚本
|
||||
2. `test_coin_cell_reset.py` - 复位功能测试(更新为 pymodbus 3.x)
|
||||
3. `test_unilab_interact.py` - Unilab 交互测试(更新为 pymodbus 3.x)
|
||||
|
||||
## 总结
|
||||
|
||||
本次更新成功实现了设备初始化过程中物料搜寻确认弹窗的自动化处理,主要优势:
|
||||
|
||||
✅ **全自动化** - 无需人工干预
|
||||
✅ **参数可配** - 灵活控制是否启用物料搜寻
|
||||
✅ **实时检测** - 在初始化等待循环中检测,避免死锁
|
||||
✅ **脉冲模式** - 模拟真实按钮操作
|
||||
✅ **向后兼容** - 保留原有函数,不影响现有流程
|
||||
✅ **完整日志** - 详细记录每一步操作
|
||||
✅ **超时保护** - 防止无限等待
|
||||
|
||||
该功能已通过实际测试验证,可投入生产使用。
|
||||
|
||||
---
|
||||
|
||||
**文档版本:** 1.0
|
||||
**创建日期:** 2026-01-13
|
||||
**作者:** Antigravity AI Assistant
|
||||
**最后更新:** 2026-01-13 21:36
|
||||
@@ -1,645 +0,0 @@
|
||||
"""
|
||||
纽扣电池组装工作站物料类定义
|
||||
Button Battery Assembly Station Resource Classes
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Dict, List, Optional, TypedDict, Union, cast
|
||||
|
||||
from pylabrobot.resources.coordinate import Coordinate
|
||||
from pylabrobot.resources.container import Container
|
||||
from pylabrobot.resources.deck import Deck
|
||||
from pylabrobot.resources.itemized_resource import ItemizedResource
|
||||
from pylabrobot.resources.resource import Resource
|
||||
from pylabrobot.resources.resource_stack import ResourceStack
|
||||
from pylabrobot.resources.tip_rack import TipRack, TipSpot
|
||||
from pylabrobot.resources.trash import Trash
|
||||
from pylabrobot.resources.utils import create_ordered_items_2d
|
||||
|
||||
from unilabos.resources.battery.magazine import MagazineHolder_4_Cathode, MagazineHolder_6_Cathode, MagazineHolder_6_Anode, MagazineHolder_6_Battery
|
||||
from unilabos.resources.battery.bottle_carriers import YIHUA_Electrolyte_12VialCarrier
|
||||
from unilabos.resources.battery.electrode_sheet import ElectrodeSheet
|
||||
|
||||
|
||||
|
||||
# TODO: 这个应该只能放一个极片
|
||||
class MaterialHoleState(TypedDict):
|
||||
diameter: int
|
||||
depth: int
|
||||
max_sheets: int
|
||||
info: Optional[str] # 附加信息
|
||||
|
||||
class MaterialHole(Resource):
|
||||
"""料板洞位类"""
|
||||
children: List[ElectrodeSheet] = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
category: str = "material_hole",
|
||||
**kwargs
|
||||
):
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
category=category,
|
||||
)
|
||||
self._unilabos_state: MaterialHoleState = MaterialHoleState(
|
||||
diameter=20,
|
||||
depth=10,
|
||||
max_sheets=1,
|
||||
info=None
|
||||
)
|
||||
|
||||
def get_all_sheet_info(self):
|
||||
info_list = []
|
||||
for sheet in self.children:
|
||||
info_list.append(sheet._unilabos_state["info"])
|
||||
return info_list
|
||||
|
||||
#这个函数函数好像没用,一般不会集中赋值质量
|
||||
def set_all_sheet_mass(self):
|
||||
for sheet in self.children:
|
||||
sheet._unilabos_state["mass"] = 0.5 # 示例:设置质量为0.5g
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""格式不变"""
|
||||
super().load_state(state)
|
||||
self._unilabos_state = state
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""格式不变"""
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||
return data
|
||||
#移动极片前先取出对象
|
||||
def get_sheet_with_name(self, name: str) -> Optional[ElectrodeSheet]:
|
||||
for sheet in self.children:
|
||||
if sheet.name == name:
|
||||
return sheet
|
||||
return None
|
||||
|
||||
def has_electrode_sheet(self) -> bool:
|
||||
"""检查洞位是否有极片"""
|
||||
return len(self.children) > 0
|
||||
|
||||
def assign_child_resource(
|
||||
self,
|
||||
resource: ElectrodeSheet,
|
||||
location: Optional[Coordinate],
|
||||
reassign: bool = True,
|
||||
):
|
||||
"""放置极片"""
|
||||
# TODO: 这里要改,diameter找不到,加入._unilabos_state后应该没问题
|
||||
#if resource._unilabos_state["diameter"] > self._unilabos_state["diameter"]:
|
||||
# raise ValueError(f"极片直径 {resource._unilabos_state['diameter']} 超过洞位直径 {self._unilabos_state['diameter']}")
|
||||
#if len(self.children) >= self._unilabos_state["max_sheets"]:
|
||||
# raise ValueError(f"洞位已满,无法放置更多极片")
|
||||
super().assign_child_resource(resource, location, reassign)
|
||||
|
||||
# 根据children的编号取物料对象。
|
||||
def get_electrode_sheet_info(self, index: int) -> ElectrodeSheet:
|
||||
return self.children[index]
|
||||
|
||||
|
||||
class MaterialPlateState(TypedDict):
|
||||
hole_spacing_x: float
|
||||
hole_spacing_y: float
|
||||
hole_diameter: float
|
||||
info: Optional[str] # 附加信息
|
||||
|
||||
class MaterialPlate(ItemizedResource[MaterialHole]):
|
||||
"""料板类 - 4x4个洞位,每个洞位放1个极片"""
|
||||
|
||||
children: List[MaterialHole]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
ordered_items: Optional[Dict[str, MaterialHole]] = None,
|
||||
ordering: Optional[OrderedDict[str, str]] = None,
|
||||
category: str = "material_plate",
|
||||
model: Optional[str] = None,
|
||||
fill: bool = False
|
||||
):
|
||||
"""初始化料板
|
||||
|
||||
Args:
|
||||
name: 料板名称
|
||||
size_x: 长度 (mm)
|
||||
size_y: 宽度 (mm)
|
||||
size_z: 高度 (mm)
|
||||
hole_diameter: 洞直径 (mm)
|
||||
hole_depth: 洞深度 (mm)
|
||||
hole_spacing_x: X方向洞位间距 (mm)
|
||||
hole_spacing_y: Y方向洞位间距 (mm)
|
||||
number: 编号
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
self._unilabos_state: MaterialPlateState = MaterialPlateState(
|
||||
hole_spacing_x=24.0,
|
||||
hole_spacing_y=24.0,
|
||||
hole_diameter=20.0,
|
||||
info="",
|
||||
)
|
||||
# 创建4x4的洞位
|
||||
# TODO: 这里要改,对应不同形状
|
||||
holes = create_ordered_items_2d(
|
||||
klass=MaterialHole,
|
||||
num_items_x=4,
|
||||
num_items_y=4,
|
||||
dx=(size_x - 4 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
|
||||
dy=(size_y - 4 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
|
||||
dz=size_z,
|
||||
item_dx=self._unilabos_state["hole_spacing_x"],
|
||||
item_dy=self._unilabos_state["hole_spacing_y"],
|
||||
size_x = 16,
|
||||
size_y = 16,
|
||||
size_z = 16,
|
||||
)
|
||||
if fill:
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
ordered_items=holes,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
else:
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
ordered_items=ordered_items,
|
||||
ordering=ordering,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
|
||||
def update_locations(self):
|
||||
# TODO:调多次相加
|
||||
holes = create_ordered_items_2d(
|
||||
klass=MaterialHole,
|
||||
num_items_x=4,
|
||||
num_items_y=4,
|
||||
dx=(self._size_x - 3 * self._unilabos_state["hole_spacing_x"]) / 2, # 居中
|
||||
dy=(self._size_y - 3 * self._unilabos_state["hole_spacing_y"]) / 2, # 居中
|
||||
dz=self._size_z,
|
||||
item_dx=self._unilabos_state["hole_spacing_x"],
|
||||
item_dy=self._unilabos_state["hole_spacing_y"],
|
||||
size_x = 1,
|
||||
size_y = 1,
|
||||
size_z = 1,
|
||||
)
|
||||
for item, original_item in zip(holes.items(), self.children):
|
||||
original_item.location = item[1].location
|
||||
|
||||
|
||||
class PlateSlot(ResourceStack):
|
||||
"""板槽位类 - 1个槽上能堆放8个板,移板只能操作最上方的板"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float,
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
max_plates: int = 8,
|
||||
category: str = "plate_slot",
|
||||
model: Optional[str] = None
|
||||
):
|
||||
"""初始化板槽位
|
||||
|
||||
Args:
|
||||
name: 槽位名称
|
||||
max_plates: 最大板数量
|
||||
category: 类别
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
direction="z", # Z方向堆叠
|
||||
resources=[],
|
||||
)
|
||||
self.max_plates = max_plates
|
||||
self.category = category
|
||||
|
||||
def can_add_plate(self) -> bool:
|
||||
"""检查是否可以添加板"""
|
||||
return len(self.children) < self.max_plates
|
||||
|
||||
def add_plate(self, plate: MaterialPlate) -> None:
|
||||
"""添加料板"""
|
||||
if not self.can_add_plate():
|
||||
raise ValueError(f"槽位 {self.name} 已满,无法添加更多板")
|
||||
self.assign_child_resource(plate)
|
||||
|
||||
def get_top_plate(self) -> MaterialPlate:
|
||||
"""获取最上方的板"""
|
||||
if len(self.children) == 0:
|
||||
raise ValueError(f"槽位 {self.name} 为空")
|
||||
return cast(MaterialPlate, self.get_top_item())
|
||||
|
||||
def take_top_plate(self) -> MaterialPlate:
|
||||
"""取出最上方的板"""
|
||||
top_plate = self.get_top_plate()
|
||||
self.unassign_child_resource(top_plate)
|
||||
return top_plate
|
||||
|
||||
def can_access_for_picking(self) -> bool:
|
||||
"""检查是否可以进行取料操作(只有最上方的板能进行取料操作)"""
|
||||
return len(self.children) > 0
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return {
|
||||
**super().serialize(),
|
||||
"max_plates": self.max_plates,
|
||||
}
|
||||
|
||||
|
||||
#是一种类型注解,不用self
|
||||
class BatteryState(TypedDict):
|
||||
"""电池状态字典"""
|
||||
diameter: float
|
||||
height: float
|
||||
assembly_pressure: float
|
||||
electrolyte_volume: float
|
||||
electrolyte_name: str
|
||||
|
||||
class Battery(Resource):
|
||||
"""电池类 - 可容纳极片"""
|
||||
children: List[ElectrodeSheet] = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x=1,
|
||||
size_y=1,
|
||||
size_z=1,
|
||||
category: str = "battery",
|
||||
):
|
||||
"""初始化电池
|
||||
|
||||
Args:
|
||||
name: 电池名称
|
||||
diameter: 直径 (mm)
|
||||
height: 高度 (mm)
|
||||
max_volume: 最大容量 (μL)
|
||||
barcode: 二维码编号
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=1,
|
||||
size_y=1,
|
||||
size_z=1,
|
||||
category=category,
|
||||
)
|
||||
self._unilabos_state: BatteryState = BatteryState(
|
||||
diameter = 1.0,
|
||||
height = 1.0,
|
||||
assembly_pressure = 1.0,
|
||||
electrolyte_volume = 1.0,
|
||||
electrolyte_name = "DP001"
|
||||
)
|
||||
|
||||
def add_electrolyte_with_bottle(self, bottle: Bottle) -> bool:
|
||||
to_add_name = bottle._unilabos_state["electrolyte_name"]
|
||||
if bottle.aspirate_electrolyte(10):
|
||||
if self.add_electrolyte(to_add_name, 10):
|
||||
pass
|
||||
else:
|
||||
bottle._unilabos_state["electrolyte_volume"] += 10
|
||||
|
||||
def set_electrolyte(self, name: str, volume: float) -> None:
|
||||
"""设置电解液信息"""
|
||||
self._unilabos_state["electrolyte_name"] = name
|
||||
self._unilabos_state["electrolyte_volume"] = volume
|
||||
#这个应该没用,不会有加了后再加的事情
|
||||
def add_electrolyte(self, name: str, volume: float) -> bool:
|
||||
"""添加电解液信息"""
|
||||
if name != self._unilabos_state["electrolyte_name"]:
|
||||
return False
|
||||
self._unilabos_state["electrolyte_volume"] += volume
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""格式不变"""
|
||||
super().load_state(state)
|
||||
self._unilabos_state = state
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""格式不变"""
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||
return data
|
||||
|
||||
# 电解液作为属性放进去
|
||||
|
||||
class BatteryPressSlotState(TypedDict):
|
||||
"""电池状态字典"""
|
||||
diameter: float =20.0
|
||||
depth: float = 4.0
|
||||
|
||||
class BatteryPressSlot(Resource):
|
||||
"""电池压制槽类 - 设备,可容纳一个电池"""
|
||||
children: List[Battery] = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "BatteryPressSlot",
|
||||
category: str = "battery_press_slot",
|
||||
):
|
||||
"""初始化电池压制槽
|
||||
|
||||
Args:
|
||||
name: 压制槽名称
|
||||
diameter: 直径 (mm)
|
||||
depth: 深度 (mm)
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=10,
|
||||
size_y=12,
|
||||
size_z=13,
|
||||
category=category,
|
||||
)
|
||||
self._unilabos_state: BatteryPressSlotState = BatteryPressSlotState()
|
||||
|
||||
def has_battery(self) -> bool:
|
||||
"""检查是否有电池"""
|
||||
return len(self.children) > 0
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""格式不变"""
|
||||
super().load_state(state)
|
||||
self._unilabos_state = state
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""格式不变"""
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||
return data
|
||||
|
||||
def assign_child_resource(
|
||||
self,
|
||||
resource: Battery,
|
||||
location: Optional[Coordinate],
|
||||
reassign: bool = True,
|
||||
):
|
||||
"""放置极片"""
|
||||
# TODO: 让高京看下槽位只有一个电池时是否这么写。
|
||||
if self.has_battery():
|
||||
raise ValueError(f"槽位已含有一个电池,无法再放置其他电池")
|
||||
super().assign_child_resource(resource, location, reassign)
|
||||
|
||||
# 根据children的编号取物料对象。
|
||||
def get_battery_info(self, index: int) -> Battery:
|
||||
return self.children[0]
|
||||
|
||||
|
||||
def TipBox64(
|
||||
name: str,
|
||||
size_x: float = 127.8,
|
||||
size_y: float = 85.5,
|
||||
size_z: float = 60.0,
|
||||
category: str = "tip_rack",
|
||||
model: Optional[str] = None,
|
||||
):
|
||||
"""64孔枪头盒类"""
|
||||
from pylabrobot.resources.tip import Tip
|
||||
|
||||
# 创建12x8=96个枪头位
|
||||
def make_tip():
|
||||
return Tip(
|
||||
has_filter=False,
|
||||
total_tip_length=20.0,
|
||||
maximal_volume=1000, # 1mL
|
||||
fitting_depth=8.0,
|
||||
)
|
||||
|
||||
tip_spots = create_ordered_items_2d(
|
||||
klass=TipSpot,
|
||||
num_items_x=12,
|
||||
num_items_y=8,
|
||||
dx=8.0,
|
||||
dy=8.0,
|
||||
dz=0.0,
|
||||
item_dx=9.0,
|
||||
item_dy=9.0,
|
||||
size_x=10,
|
||||
size_y=10,
|
||||
size_z=0.0,
|
||||
make_tip=make_tip,
|
||||
)
|
||||
idx_available = list(range(0, 32)) + list(range(64, 96))
|
||||
tip_spots_available = {k: v for i, (k, v) in enumerate(tip_spots.items()) if i in idx_available}
|
||||
tip_rack = TipRack(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
# ordered_items=tip_spots_available,
|
||||
ordered_items=tip_spots,
|
||||
category=category,
|
||||
model=model,
|
||||
with_tips=False,
|
||||
)
|
||||
tip_rack.set_tip_state([True]*32 + [False]*32 + [True]*32) # 前32和后32个有枪头,中间32个无枪头
|
||||
return tip_rack
|
||||
|
||||
|
||||
class WasteTipBoxstate(TypedDict):
|
||||
""""废枪头盒状态字典"""
|
||||
max_tips: int = 100
|
||||
tip_count: int = 0
|
||||
|
||||
#枪头不是一次性的(同一溶液则反复使用),根据寄存器判断
|
||||
class WasteTipBox(Trash):
|
||||
"""废枪头盒类 - 100个枪头容量"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
size_x: float = 127.8,
|
||||
size_y: float = 85.5,
|
||||
size_z: float = 60.0,
|
||||
material_z_thickness=0,
|
||||
max_volume=float("inf"),
|
||||
category="trash",
|
||||
model=None,
|
||||
compute_volume_from_height=None,
|
||||
compute_height_from_volume=None,
|
||||
):
|
||||
"""初始化废枪头盒
|
||||
|
||||
Args:
|
||||
name: 废枪头盒名称
|
||||
size_x: 长度 (mm)
|
||||
size_y: 宽度 (mm)
|
||||
size_z: 高度 (mm)
|
||||
max_tips: 最大枪头容量
|
||||
category: 类别
|
||||
model: 型号
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=size_x,
|
||||
size_y=size_y,
|
||||
size_z=size_z,
|
||||
category=category,
|
||||
model=model,
|
||||
)
|
||||
self._unilabos_state: WasteTipBoxstate = WasteTipBoxstate()
|
||||
|
||||
def add_tip(self) -> None:
|
||||
"""添加废枪头"""
|
||||
if self._unilabos_state["tip_count"] >= self._unilabos_state["max_tips"]:
|
||||
raise ValueError(f"废枪头盒 {self.name} 已满")
|
||||
self._unilabos_state["tip_count"] += 1
|
||||
|
||||
def get_tip_count(self) -> int:
|
||||
"""获取枪头数量"""
|
||||
return self._unilabos_state["tip_count"]
|
||||
|
||||
def empty(self) -> None:
|
||||
"""清空废枪头盒"""
|
||||
self._unilabos_state["tip_count"] = 0
|
||||
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
"""格式不变"""
|
||||
super().load_state(state)
|
||||
self._unilabos_state = state
|
||||
|
||||
def serialize_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""格式不变"""
|
||||
data = super().serialize_state()
|
||||
data.update(self._unilabos_state) # Container自身的信息,云端物料将保存这一data,本地也通过这里的data进行读写,当前类用来表示这个物料的长宽高大小的属性,而data(state用来表示物料的内容,细节等)
|
||||
return data
|
||||
|
||||
|
||||
class CoincellDeck(Deck):
|
||||
"""纽扣电池组装工作站台面类"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "coin_cell_deck",
|
||||
size_x: float = 1450.0, # 1m
|
||||
size_y: float = 1450.0, # 1m
|
||||
size_z: float = 100.0, # 0.9m
|
||||
origin: Coordinate = Coordinate(-2200, 0, 0),
|
||||
category: str = "coin_cell_deck",
|
||||
setup: bool = False, # 是否自动执行 setup
|
||||
):
|
||||
"""初始化纽扣电池组装工作站台面
|
||||
|
||||
Args:
|
||||
name: 台面名称
|
||||
size_x: 长度 (mm) - 1m
|
||||
size_y: 宽度 (mm) - 1m
|
||||
size_z: 高度 (mm) - 0.9m
|
||||
origin: 原点坐标
|
||||
category: 类别
|
||||
setup: 是否自动执行 setup 配置标准布局
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
size_x=1450.0,
|
||||
size_y=1450.0,
|
||||
size_z=100.0,
|
||||
origin=origin,
|
||||
)
|
||||
if setup:
|
||||
self.setup()
|
||||
|
||||
def setup(self) -> None:
|
||||
"""设置工作站的标准布局 - 包含子弹夹、料盘、瓶架等完整配置"""
|
||||
# ====================================== 子弹夹 ============================================
|
||||
|
||||
# 正极片(4个洞位,2x2布局)
|
||||
zhengji_zip = MagazineHolder_4_Cathode("正极&铝箔弹夹")
|
||||
self.assign_child_resource(zhengji_zip, Coordinate(x=402.0, y=830.0, z=0))
|
||||
|
||||
# 正极壳、平垫片(6个洞位,2x2+2布局)
|
||||
zhengjike_zip = MagazineHolder_6_Cathode("正极壳&平垫片弹夹")
|
||||
self.assign_child_resource(zhengjike_zip, Coordinate(x=566.0, y=272.0, z=0))
|
||||
|
||||
# 负极壳、弹垫片(6个洞位,2x2+2布局)
|
||||
fujike_zip = MagazineHolder_6_Anode("负极壳&弹垫片弹夹")
|
||||
self.assign_child_resource(fujike_zip, Coordinate(x=474.0, y=276.0, z=0))
|
||||
|
||||
# 成品弹夹(6个洞位,3x2布局)
|
||||
chengpindanjia_zip = MagazineHolder_6_Battery("成品弹夹")
|
||||
self.assign_child_resource(chengpindanjia_zip, Coordinate(x=260.0, y=156.0, z=0))
|
||||
|
||||
# ====================================== 物料板 ============================================
|
||||
# 创建物料板(料盘carrier)- 4x4布局
|
||||
# 负极料盘
|
||||
fujiliaopan = MaterialPlate(name="负极料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||
self.assign_child_resource(fujiliaopan, Coordinate(x=708.0, y=794.0, z=0))
|
||||
# for i in range(16):
|
||||
# fujipian = ElectrodeSheet(name=f"{fujiliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
# fujiliaopan.children[i].assign_child_resource(fujipian, location=None)
|
||||
|
||||
# 隔膜料盘
|
||||
gemoliaopan = MaterialPlate(name="隔膜料盘", size_x=120, size_y=100, size_z=10.0, fill=True)
|
||||
self.assign_child_resource(gemoliaopan, Coordinate(x=718.0, y=918.0, z=0))
|
||||
# for i in range(16):
|
||||
# gemopian = ElectrodeSheet(name=f"{gemoliaopan.name}_jipian_{i}", size_x=12, size_y=12, size_z=0.1)
|
||||
# gemoliaopan.children[i].assign_child_resource(gemopian, location=None)
|
||||
|
||||
# ====================================== 瓶架、移液枪 ============================================
|
||||
# 在台面上放置 3x4 瓶架、6x2 瓶架 与 64孔移液枪头盒
|
||||
# 奔耀上料5ml分液瓶小板 - 由奔曜跨站转运而来,不单独写,但是这里应该有一个堆栈用于摆放分液瓶小板
|
||||
|
||||
# bottle_rack_3x4 = BottleRack(
|
||||
# name="bottle_rack_3x4",
|
||||
# size_x=210.0,
|
||||
# size_y=140.0,
|
||||
# size_z=100.0,
|
||||
# num_items_x=2,
|
||||
# num_items_y=4,
|
||||
# position_spacing=35.0,
|
||||
# orientation="vertical",
|
||||
# )
|
||||
# self.assign_child_resource(bottle_rack_3x4, Coordinate(x=1542.0, y=717.0, z=0))
|
||||
|
||||
# 电解液缓存位 - 6x2布局
|
||||
bottle_rack_6x2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2")
|
||||
self.assign_child_resource(bottle_rack_6x2, Coordinate(x=1050.0, y=358.0, z=0))
|
||||
# 电解液回收位6x2
|
||||
bottle_rack_6x2_2 = YIHUA_Electrolyte_12VialCarrier(name="bottle_rack_6x2_2")
|
||||
self.assign_child_resource(bottle_rack_6x2_2, Coordinate(x=914.0, y=358.0, z=0))
|
||||
|
||||
tip_box = TipBox64(name="tip_box_64")
|
||||
self.assign_child_resource(tip_box, Coordinate(x=782.0, y=514.0, z=0))
|
||||
|
||||
waste_tip_box = WasteTipBox(name="waste_tip_box")
|
||||
self.assign_child_resource(waste_tip_box, Coordinate(x=778.0, y=622.0, z=0))
|
||||
|
||||
|
||||
def YH_Deck(name=""):
|
||||
cd = CoincellDeck(name=name)
|
||||
cd.setup()
|
||||
return cd
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
deck = create_coin_cell_deck()
|
||||
print(deck)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,133 +0,0 @@
|
||||
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-电解液瓶盖在籍异常
|
||||
|
14472
unilabos/devices/workstation/coin_cell_assembly/new_cellconfig4.json
Normal file
14472
unilabos/devices/workstation/coin_cell_assembly/new_cellconfig4.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -459,12 +459,12 @@ class WorkstationHTTPHandler(BaseHTTPRequestHandler):
|
||||
# 验证必需字段
|
||||
if 'brand' in request_data:
|
||||
if request_data['brand'] == "bioyond": # 奔曜
|
||||
material_data = request_data["text"]
|
||||
logger.info(f"收到奔曜物料变更报送: {material_data}")
|
||||
error_msg = request_data["text"]
|
||||
logger.info(f"收到奔曜错误处理报送: {error_msg}")
|
||||
return HttpResponse(
|
||||
success=True,
|
||||
message=f"物料变更报送已收到: {material_data}",
|
||||
acknowledgment_id=f"MATERIAL_{int(time.time() * 1000)}_{material_data.get('id', 'unknown')}",
|
||||
message=f"错误处理报送已收到: {error_msg}",
|
||||
acknowledgment_id=f"ERROR_{int(time.time() * 1000)}_{error_msg.get('action_id', 'unknown')}",
|
||||
data=None
|
||||
)
|
||||
else:
|
||||
|
||||
1
unilabos/labware_manager/__init__.py
Normal file
1
unilabos/labware_manager/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# PRCXI 耗材管理 Web 应用
|
||||
4
unilabos/labware_manager/__main__.py
Normal file
4
unilabos/labware_manager/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""启动入口: python -m unilabos.labware_manager"""
|
||||
from unilabos.labware_manager.app import main
|
||||
|
||||
main()
|
||||
196
unilabos/labware_manager/app.py
Normal file
196
unilabos/labware_manager/app.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""FastAPI 应用 + CRUD API + 启动入口。
|
||||
|
||||
用法: python -m unilabos.labware_manager.app
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from unilabos.labware_manager.models import LabwareDB, LabwareItem
|
||||
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
_DB_PATH = _HERE / "labware_db.json"
|
||||
|
||||
app = FastAPI(title="PRCXI 耗材管理", version="1.0")
|
||||
|
||||
# 静态文件 + 模板
|
||||
app.mount("/static", StaticFiles(directory=str(_HERE / "static")), name="static")
|
||||
templates = Jinja2Templates(directory=str(_HERE / "templates"))
|
||||
|
||||
|
||||
# ---------- DB 读写 ----------
|
||||
|
||||
def _load_db() -> LabwareDB:
|
||||
if not _DB_PATH.exists():
|
||||
return LabwareDB()
|
||||
with open(_DB_PATH, "r", encoding="utf-8") as f:
|
||||
return LabwareDB(**json.load(f))
|
||||
|
||||
|
||||
def _save_db(db: LabwareDB) -> None:
|
||||
with open(_DB_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(db.model_dump(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
# ---------- 页面路由 ----------
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index_page(request: Request):
|
||||
db = _load_db()
|
||||
# 按 type 分组
|
||||
groups = {}
|
||||
for item in db.items:
|
||||
groups.setdefault(item.type, []).append(item)
|
||||
return templates.TemplateResponse("index.html", {
|
||||
"request": request,
|
||||
"groups": groups,
|
||||
"total": len(db.items),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/labware/new", response_class=HTMLResponse)
|
||||
async def new_page(request: Request, type: str = "plate"):
|
||||
return templates.TemplateResponse("edit.html", {
|
||||
"request": request,
|
||||
"item": None,
|
||||
"labware_type": type,
|
||||
"is_new": True,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/labware/{item_id}", response_class=HTMLResponse)
|
||||
async def detail_page(request: Request, item_id: str):
|
||||
db = _load_db()
|
||||
item = _find_item(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(404, "耗材不存在")
|
||||
return templates.TemplateResponse("detail.html", {
|
||||
"request": request,
|
||||
"item": item,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/labware/{item_id}/edit", response_class=HTMLResponse)
|
||||
async def edit_page(request: Request, item_id: str):
|
||||
db = _load_db()
|
||||
item = _find_item(db, item_id)
|
||||
if not item:
|
||||
raise HTTPException(404, "耗材不存在")
|
||||
return templates.TemplateResponse("edit.html", {
|
||||
"request": request,
|
||||
"item": item,
|
||||
"labware_type": item.type,
|
||||
"is_new": False,
|
||||
})
|
||||
|
||||
|
||||
# ---------- API 端点 ----------
|
||||
|
||||
@app.get("/api/labware")
|
||||
async def api_list_labware():
|
||||
db = _load_db()
|
||||
return {"items": [item.model_dump() for item in db.items]}
|
||||
|
||||
|
||||
@app.post("/api/labware")
|
||||
async def api_create_labware(request: Request):
|
||||
data = await request.json()
|
||||
db = _load_db()
|
||||
item = LabwareItem(**data)
|
||||
# 确保 id 唯一
|
||||
existing_ids = {it.id for it in db.items}
|
||||
while item.id in existing_ids:
|
||||
import uuid
|
||||
item.id = uuid.uuid4().hex[:8]
|
||||
db.items.append(item)
|
||||
_save_db(db)
|
||||
return {"status": "ok", "id": item.id}
|
||||
|
||||
|
||||
@app.put("/api/labware/{item_id}")
|
||||
async def api_update_labware(item_id: str, request: Request):
|
||||
data = await request.json()
|
||||
db = _load_db()
|
||||
for i, it in enumerate(db.items):
|
||||
if it.id == item_id or it.function_name == item_id:
|
||||
updated = LabwareItem(**{**it.model_dump(), **data, "id": it.id})
|
||||
db.items[i] = updated
|
||||
_save_db(db)
|
||||
return {"status": "ok", "id": it.id}
|
||||
raise HTTPException(404, "耗材不存在")
|
||||
|
||||
|
||||
@app.delete("/api/labware/{item_id}")
|
||||
async def api_delete_labware(item_id: str):
|
||||
db = _load_db()
|
||||
original_len = len(db.items)
|
||||
db.items = [it for it in db.items if it.id != item_id and it.function_name != item_id]
|
||||
if len(db.items) == original_len:
|
||||
raise HTTPException(404, "耗材不存在")
|
||||
_save_db(db)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/generate-code")
|
||||
async def api_generate_code(request: Request):
|
||||
body = await request.json() if await request.body() else {}
|
||||
test_mode = body.get("test_mode", True)
|
||||
db = _load_db()
|
||||
if not db.items:
|
||||
raise HTTPException(400, "数据库为空,请先导入")
|
||||
|
||||
from unilabos.labware_manager.codegen import generate_code
|
||||
from unilabos.labware_manager.yaml_gen import generate_yaml
|
||||
|
||||
py_path = generate_code(db, test_mode=test_mode)
|
||||
yaml_paths = generate_yaml(db, test_mode=test_mode)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"python_file": str(py_path),
|
||||
"yaml_files": [str(p) for p in yaml_paths],
|
||||
"test_mode": test_mode,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/import-from-code")
|
||||
async def api_import_from_code():
|
||||
from unilabos.labware_manager.importer import import_from_code, save_db
|
||||
db = import_from_code()
|
||||
save_db(db)
|
||||
return {
|
||||
"status": "ok",
|
||||
"count": len(db.items),
|
||||
"items": [{"function_name": it.function_name, "type": it.type} for it in db.items],
|
||||
}
|
||||
|
||||
|
||||
# ---------- 辅助函数 ----------
|
||||
|
||||
def _find_item(db: LabwareDB, item_id: str) -> Optional[LabwareItem]:
|
||||
for item in db.items:
|
||||
if item.id == item_id or item.function_name == item_id:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
# ---------- 启动入口 ----------
|
||||
|
||||
def main():
|
||||
import uvicorn
|
||||
port = int(os.environ.get("LABWARE_PORT", "8010"))
|
||||
print(f"PRCXI 耗材管理 → http://localhost:{port}")
|
||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
451
unilabos/labware_manager/codegen.py
Normal file
451
unilabos/labware_manager/codegen.py
Normal file
@@ -0,0 +1,451 @@
|
||||
"""JSON → prcxi_labware.py 代码生成。
|
||||
|
||||
读取 labware_db.json,输出完整的 prcxi_labware.py(或 prcxi_labware_test.py)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from unilabos.labware_manager.models import LabwareDB, LabwareItem
|
||||
|
||||
_TARGET_DIR = Path(__file__).resolve().parents[1] / "devices" / "liquid_handling" / "prcxi"
|
||||
|
||||
# ---------- 固定头部 ----------
|
||||
_HEADER = '''\
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from pylabrobot.resources import Tube, Coordinate
|
||||
from pylabrobot.resources.well import Well, WellBottomType, CrossSectionType
|
||||
from pylabrobot.resources.tip import Tip, TipCreator
|
||||
from pylabrobot.resources.tip_rack import TipRack, TipSpot
|
||||
from pylabrobot.resources.utils import create_ordered_items_2d
|
||||
from pylabrobot.resources.height_volume_functions import (
|
||||
compute_height_from_volume_rectangle,
|
||||
compute_volume_from_height_rectangle,
|
||||
)
|
||||
|
||||
from .prcxi import PRCXI9300Plate, PRCXI9300TipRack, PRCXI9300Trash, PRCXI9300TubeRack, PRCXI9300PlateAdapter
|
||||
|
||||
def _make_tip_helper(volume: float, length: float, depth: float) -> Tip:
|
||||
"""
|
||||
PLR 的 Tip 类参数名为: maximal_volume, total_tip_length, fitting_depth
|
||||
"""
|
||||
return Tip(
|
||||
has_filter=False, # 默认无滤芯
|
||||
maximal_volume=volume,
|
||||
total_tip_length=length,
|
||||
fitting_depth=depth
|
||||
)
|
||||
|
||||
'''
|
||||
|
||||
|
||||
def _gen_plate(item: LabwareItem) -> str:
|
||||
"""生成 Plate 类型的工厂函数代码。"""
|
||||
lines = []
|
||||
fn = item.function_name
|
||||
doc = item.docstring or f"Code: {item.material_info.Code}"
|
||||
|
||||
has_vf = item.volume_functions is not None
|
||||
|
||||
if has_vf:
|
||||
# 有 volume_functions 时需要 well_kwargs 方式
|
||||
vf = item.volume_functions
|
||||
well = item.well
|
||||
grid = item.grid
|
||||
|
||||
lines.append(f'def {fn}(name: str) -> PRCXI9300Plate:')
|
||||
lines.append(f' """')
|
||||
for dl in doc.split('\n'):
|
||||
lines.append(f' {dl}')
|
||||
lines.append(f' """')
|
||||
|
||||
# 计算 well_size 变量
|
||||
lines.append(f' well_size_x = {well.size_x}')
|
||||
lines.append(f' well_size_y = {well.size_y}')
|
||||
|
||||
lines.append(f' well_kwargs = {{')
|
||||
lines.append(f' "size_x": well_size_x,')
|
||||
lines.append(f' "size_y": well_size_y,')
|
||||
lines.append(f' "size_z": {well.size_z},')
|
||||
lines.append(f' "bottom_type": WellBottomType.{well.bottom_type},')
|
||||
if well.cross_section_type and well.cross_section_type != "CIRCLE":
|
||||
lines.append(f' "cross_section_type": CrossSectionType.{well.cross_section_type},')
|
||||
lines.append(f' "compute_height_from_volume": lambda liquid_volume: compute_height_from_volume_rectangle(')
|
||||
lines.append(f' liquid_volume=liquid_volume, well_length=well_size_x, well_width=well_size_y')
|
||||
lines.append(f' ),')
|
||||
lines.append(f' "compute_volume_from_height": lambda liquid_height: compute_volume_from_height_rectangle(')
|
||||
lines.append(f' liquid_height=liquid_height, well_length=well_size_x, well_width=well_size_y')
|
||||
lines.append(f' ),')
|
||||
if well.material_z_thickness is not None:
|
||||
lines.append(f' "material_z_thickness": {well.material_z_thickness},')
|
||||
lines.append(f' }}')
|
||||
lines.append(f'')
|
||||
lines.append(f' return PRCXI9300Plate(')
|
||||
lines.append(f' name=name,')
|
||||
lines.append(f' size_x={item.size_x},')
|
||||
lines.append(f' size_y={item.size_y},')
|
||||
lines.append(f' size_z={item.size_z},')
|
||||
lines.append(f' lid=None,')
|
||||
lines.append(f' model="{item.model}",')
|
||||
lines.append(f' category="plate",')
|
||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
|
||||
lines.append(f' ordered_items=create_ordered_items_2d(')
|
||||
lines.append(f' Well,')
|
||||
lines.append(f' num_items_x={grid.num_items_x},')
|
||||
lines.append(f' num_items_y={grid.num_items_y},')
|
||||
lines.append(f' dx={grid.dx},')
|
||||
lines.append(f' dy={grid.dy},')
|
||||
lines.append(f' dz={grid.dz},')
|
||||
lines.append(f' item_dx={grid.item_dx},')
|
||||
lines.append(f' item_dy={grid.item_dy},')
|
||||
lines.append(f' **well_kwargs,')
|
||||
lines.append(f' ),')
|
||||
lines.append(f' )')
|
||||
else:
|
||||
# 普通 plate
|
||||
well = item.well
|
||||
grid = item.grid
|
||||
|
||||
lines.append(f'def {fn}(name: str) -> PRCXI9300Plate:')
|
||||
lines.append(f' """')
|
||||
for dl in doc.split('\n'):
|
||||
lines.append(f' {dl}')
|
||||
lines.append(f' """')
|
||||
lines.append(f' return PRCXI9300Plate(')
|
||||
lines.append(f' name=name,')
|
||||
lines.append(f' size_x={item.size_x},')
|
||||
lines.append(f' size_y={item.size_y},')
|
||||
lines.append(f' size_z={item.size_z},')
|
||||
if item.plate_type:
|
||||
lines.append(f' plate_type="{item.plate_type}",')
|
||||
lines.append(f' model="{item.model}",')
|
||||
lines.append(f' category="plate",')
|
||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
|
||||
if grid and well:
|
||||
lines.append(f' ordered_items=create_ordered_items_2d(')
|
||||
lines.append(f' Well,')
|
||||
lines.append(f' num_items_x={grid.num_items_x},')
|
||||
lines.append(f' num_items_y={grid.num_items_y},')
|
||||
lines.append(f' dx={grid.dx},')
|
||||
lines.append(f' dy={grid.dy},')
|
||||
lines.append(f' dz={grid.dz},')
|
||||
lines.append(f' item_dx={grid.item_dx},')
|
||||
lines.append(f' item_dy={grid.item_dy},')
|
||||
lines.append(f' size_x={well.size_x},')
|
||||
lines.append(f' size_y={well.size_y},')
|
||||
lines.append(f' size_z={well.size_z},')
|
||||
if well.max_volume is not None:
|
||||
lines.append(f' max_volume={well.max_volume},')
|
||||
if well.material_z_thickness is not None:
|
||||
lines.append(f' material_z_thickness={well.material_z_thickness},')
|
||||
if well.bottom_type and well.bottom_type != "FLAT":
|
||||
lines.append(f' bottom_type=WellBottomType.{well.bottom_type},')
|
||||
if well.cross_section_type:
|
||||
lines.append(f' cross_section_type=CrossSectionType.{well.cross_section_type},')
|
||||
lines.append(f' ),')
|
||||
lines.append(f' )')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _gen_tip_rack(item: LabwareItem) -> str:
|
||||
"""生成 TipRack 工厂函数代码。"""
|
||||
lines = []
|
||||
fn = item.function_name
|
||||
doc = item.docstring or f"Code: {item.material_info.Code}"
|
||||
grid = item.grid
|
||||
tip = item.tip
|
||||
|
||||
lines.append(f'def {fn}(name: str) -> PRCXI9300TipRack:')
|
||||
lines.append(f' """')
|
||||
for dl in doc.split('\n'):
|
||||
lines.append(f' {dl}')
|
||||
lines.append(f' """')
|
||||
lines.append(f' return PRCXI9300TipRack(')
|
||||
lines.append(f' name=name,')
|
||||
lines.append(f' size_x={item.size_x},')
|
||||
lines.append(f' size_y={item.size_y},')
|
||||
lines.append(f' size_z={item.size_z},')
|
||||
lines.append(f' model="{item.model}",')
|
||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
|
||||
if grid and tip:
|
||||
lines.append(f' ordered_items=create_ordered_items_2d(')
|
||||
lines.append(f' TipSpot,')
|
||||
lines.append(f' num_items_x={grid.num_items_x},')
|
||||
lines.append(f' num_items_y={grid.num_items_y},')
|
||||
lines.append(f' dx={grid.dx},')
|
||||
lines.append(f' dy={grid.dy},')
|
||||
lines.append(f' dz={grid.dz},')
|
||||
lines.append(f' item_dx={grid.item_dx},')
|
||||
lines.append(f' item_dy={grid.item_dy},')
|
||||
lines.append(f' size_x={tip.spot_size_x},')
|
||||
lines.append(f' size_y={tip.spot_size_y},')
|
||||
lines.append(f' size_z={tip.spot_size_z},')
|
||||
lines.append(f' make_tip=lambda: _make_tip_helper(volume={tip.tip_volume}, length={tip.tip_length}, depth={tip.tip_fitting_depth})')
|
||||
lines.append(f' )')
|
||||
lines.append(f' )')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _gen_trash(item: LabwareItem) -> str:
|
||||
"""生成 Trash 工厂函数代码。"""
|
||||
lines = []
|
||||
fn = item.function_name
|
||||
doc = item.docstring or f"Code: {item.material_info.Code}"
|
||||
|
||||
lines.append(f'def {fn}(name: str = "trash") -> PRCXI9300Trash:')
|
||||
lines.append(f' """')
|
||||
for dl in doc.split('\n'):
|
||||
lines.append(f' {dl}')
|
||||
lines.append(f' """')
|
||||
lines.append(f' return PRCXI9300Trash(')
|
||||
lines.append(f' name="trash",')
|
||||
lines.append(f' size_x={item.size_x},')
|
||||
lines.append(f' size_y={item.size_y},')
|
||||
lines.append(f' size_z={item.size_z},')
|
||||
lines.append(f' category="trash",')
|
||||
lines.append(f' model="{item.model}",')
|
||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))}')
|
||||
lines.append(f' )')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _gen_tube_rack(item: LabwareItem) -> str:
|
||||
"""生成 TubeRack 工厂函数代码。"""
|
||||
lines = []
|
||||
fn = item.function_name
|
||||
doc = item.docstring or f"Code: {item.material_info.Code}"
|
||||
grid = item.grid
|
||||
tube = item.tube
|
||||
|
||||
lines.append(f'def {fn}(name: str) -> PRCXI9300TubeRack:')
|
||||
lines.append(f' """')
|
||||
for dl in doc.split('\n'):
|
||||
lines.append(f' {dl}')
|
||||
lines.append(f' """')
|
||||
lines.append(f' return PRCXI9300TubeRack(')
|
||||
lines.append(f' name=name,')
|
||||
lines.append(f' size_x={item.size_x},')
|
||||
lines.append(f' size_y={item.size_y},')
|
||||
lines.append(f' size_z={item.size_z},')
|
||||
lines.append(f' model="{item.model}",')
|
||||
lines.append(f' category="tube_rack",')
|
||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))},')
|
||||
if grid and tube:
|
||||
lines.append(f' ordered_items=create_ordered_items_2d(')
|
||||
lines.append(f' Tube,')
|
||||
lines.append(f' num_items_x={grid.num_items_x},')
|
||||
lines.append(f' num_items_y={grid.num_items_y},')
|
||||
lines.append(f' dx={grid.dx},')
|
||||
lines.append(f' dy={grid.dy},')
|
||||
lines.append(f' dz={grid.dz},')
|
||||
lines.append(f' item_dx={grid.item_dx},')
|
||||
lines.append(f' item_dy={grid.item_dy},')
|
||||
lines.append(f' size_x={tube.size_x},')
|
||||
lines.append(f' size_y={tube.size_y},')
|
||||
lines.append(f' size_z={tube.size_z},')
|
||||
lines.append(f' max_volume={tube.max_volume}')
|
||||
lines.append(f' )')
|
||||
lines.append(f' )')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _gen_plate_adapter(item: LabwareItem) -> str:
|
||||
"""生成 PlateAdapter 工厂函数代码。"""
|
||||
lines = []
|
||||
fn = item.function_name
|
||||
doc = item.docstring or f"Code: {item.material_info.Code}"
|
||||
|
||||
lines.append(f'def {fn}(name: str) -> PRCXI9300PlateAdapter:')
|
||||
lines.append(f' """ {doc} """')
|
||||
lines.append(f' return PRCXI9300PlateAdapter(')
|
||||
lines.append(f' name=name,')
|
||||
lines.append(f' size_x={item.size_x},')
|
||||
lines.append(f' size_y={item.size_y},')
|
||||
lines.append(f' size_z={item.size_z},')
|
||||
if item.model:
|
||||
lines.append(f' model="{item.model}",')
|
||||
lines.append(f' material_info={_fmt_dict(item.material_info.model_dump(exclude_none=True))}')
|
||||
lines.append(f' )')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _fmt_dict(d: dict) -> str:
|
||||
"""格式化字典为 Python 代码片段。"""
|
||||
parts = []
|
||||
for k, v in d.items():
|
||||
if isinstance(v, str):
|
||||
parts.append(f'"{k}": "{v}"')
|
||||
elif v is None:
|
||||
continue
|
||||
else:
|
||||
parts.append(f'"{k}": {v}')
|
||||
return '{' + ', '.join(parts) + '}'
|
||||
|
||||
|
||||
def _gen_template_factory_kinds(items: List[LabwareItem]) -> str:
|
||||
"""生成 PRCXI_TEMPLATE_FACTORY_KINDS 列表。"""
|
||||
lines = ['PRCXI_TEMPLATE_FACTORY_KINDS: List[Tuple[Callable[..., Any], str]] = [']
|
||||
for item in items:
|
||||
if item.include_in_template_matching and item.template_kind:
|
||||
lines.append(f' ({item.function_name}, "{item.template_kind}"),')
|
||||
lines.append(']')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _gen_footer() -> str:
|
||||
"""生成文件尾部的模板相关代码。"""
|
||||
return '''
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 协议上传 / workflow 用:与设备端耗材字典字段对齐的模板描述(供 common 自动匹配)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PRCXI_TEMPLATE_SPECS_CACHE: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
|
||||
def _probe_prcxi_resource(factory: Callable[..., Any]) -> Any:
|
||||
probe = "__unilab_template_probe__"
|
||||
if factory.__name__ == "PRCXI_trash":
|
||||
return factory()
|
||||
return factory(probe)
|
||||
|
||||
|
||||
def _first_child_capacity_for_match(resource: Any) -> float:
|
||||
"""Well max_volume 或 Tip 的 maximal_volume,用于与设备端 Volume 类似的打分。"""
|
||||
ch = getattr(resource, "children", None) or []
|
||||
if not ch:
|
||||
return 0.0
|
||||
c0 = ch[0]
|
||||
mv = getattr(c0, "max_volume", None)
|
||||
if mv is not None:
|
||||
return float(mv)
|
||||
tip = getattr(c0, "tip", None)
|
||||
if tip is not None:
|
||||
mv2 = getattr(tip, "maximal_volume", None)
|
||||
if mv2 is not None:
|
||||
return float(mv2)
|
||||
return 0.0
|
||||
|
||||
|
||||
def get_prcxi_labware_template_specs() -> List[Dict[str, Any]]:
|
||||
"""返回与 ``prcxi._match_and_create_matrix`` 中耗材字段兼容的模板列表,用于按孔数+容量打分。"""
|
||||
global _PRCXI_TEMPLATE_SPECS_CACHE
|
||||
if _PRCXI_TEMPLATE_SPECS_CACHE is not None:
|
||||
return _PRCXI_TEMPLATE_SPECS_CACHE
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
for factory, kind in PRCXI_TEMPLATE_FACTORY_KINDS:
|
||||
try:
|
||||
r = _probe_prcxi_resource(factory)
|
||||
except Exception:
|
||||
continue
|
||||
nx = int(getattr(r, "num_items_x", None) or 0)
|
||||
ny = int(getattr(r, "num_items_y", None) or 0)
|
||||
nchild = len(getattr(r, "children", []) or [])
|
||||
hole_count = nx * ny if nx > 0 and ny > 0 else nchild
|
||||
hole_row = ny if nx > 0 and ny > 0 else 0
|
||||
hole_col = nx if nx > 0 and ny > 0 else 0
|
||||
mi = getattr(r, "material_info", None) or {}
|
||||
vol = _first_child_capacity_for_match(r)
|
||||
menum = mi.get("materialEnum")
|
||||
if menum is None and kind == "tip_rack":
|
||||
menum = 1
|
||||
elif menum is None and kind == "trash":
|
||||
menum = 6
|
||||
out.append(
|
||||
{
|
||||
"class_name": factory.__name__,
|
||||
"kind": kind,
|
||||
"materialEnum": menum,
|
||||
"HoleRow": hole_row,
|
||||
"HoleColum": hole_col,
|
||||
"Volume": vol,
|
||||
"hole_count": hole_count,
|
||||
"material_uuid": mi.get("uuid"),
|
||||
"material_code": mi.get("Code"),
|
||||
}
|
||||
)
|
||||
|
||||
_PRCXI_TEMPLATE_SPECS_CACHE = out
|
||||
return out
|
||||
'''
|
||||
|
||||
|
||||
def generate_code(db: LabwareDB, test_mode: bool = True) -> Path:
|
||||
"""生成 prcxi_labware.py (或 _test.py),返回输出文件路径。"""
|
||||
suffix = "_test" if test_mode else ""
|
||||
out_path = _TARGET_DIR / f"prcxi_labware{suffix}.py"
|
||||
|
||||
# 备份
|
||||
if out_path.exists():
|
||||
bak = out_path.with_suffix(".py.bak")
|
||||
shutil.copy2(out_path, bak)
|
||||
|
||||
# 按类型分组的生成器
|
||||
generators = {
|
||||
"plate": _gen_plate,
|
||||
"tip_rack": _gen_tip_rack,
|
||||
"trash": _gen_trash,
|
||||
"tube_rack": _gen_tube_rack,
|
||||
"plate_adapter": _gen_plate_adapter,
|
||||
}
|
||||
|
||||
# 按 type 分段
|
||||
sections = {
|
||||
"plate": [],
|
||||
"tip_rack": [],
|
||||
"trash": [],
|
||||
"tube_rack": [],
|
||||
"plate_adapter": [],
|
||||
}
|
||||
|
||||
for item in db.items:
|
||||
gen = generators.get(item.type)
|
||||
if gen:
|
||||
sections[item.type].append(gen(item))
|
||||
|
||||
# 组装完整文件
|
||||
parts = [_HEADER]
|
||||
|
||||
section_titles = {
|
||||
"plate": "# =========================================================================\n# Plates\n# =========================================================================",
|
||||
"tip_rack": "# =========================================================================\n# Tip Racks\n# =========================================================================",
|
||||
"trash": "# =========================================================================\n# Trash\n# =========================================================================",
|
||||
"tube_rack": "# =========================================================================\n# Tube Racks\n# =========================================================================",
|
||||
"plate_adapter": "# =========================================================================\n# Plate Adapters\n# =========================================================================",
|
||||
}
|
||||
|
||||
for type_key in ["plate", "tip_rack", "trash", "tube_rack", "plate_adapter"]:
|
||||
if sections[type_key]:
|
||||
parts.append(section_titles[type_key])
|
||||
for code in sections[type_key]:
|
||||
parts.append(code)
|
||||
|
||||
# Template factory kinds
|
||||
parts.append("")
|
||||
parts.append(_gen_template_factory_kinds(db.items))
|
||||
|
||||
# Footer
|
||||
parts.append(_gen_footer())
|
||||
|
||||
content = '\n'.join(parts)
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
return out_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from unilabos.labware_manager.importer import load_db
|
||||
db = load_db()
|
||||
if not db.items:
|
||||
print("labware_db.json 为空,请先运行 importer.py")
|
||||
else:
|
||||
out = generate_code(db, test_mode=True)
|
||||
print(f"已生成 {out} ({len(db.items)} 个工厂函数)")
|
||||
474
unilabos/labware_manager/importer.py
Normal file
474
unilabos/labware_manager/importer.py
Normal file
@@ -0,0 +1,474 @@
|
||||
"""从现有 prcxi_labware.py + registry YAML 导入耗材数据到 labware_db.json。
|
||||
|
||||
策略:
|
||||
1. 实例化每个工厂函数 → 提取物理尺寸、material_info、children
|
||||
2. AST 解析源码 → 提取 docstring、volume_function 参数、plate_type
|
||||
3. 从 children[0].location 反推 dx/dy/dz,相邻位置差推 item_dx/item_dy
|
||||
4. 同时读取现有 YAML → 提取 registry_category / description
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
# 将项目根目录加入 sys.path 以便 import
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(_PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_PROJECT_ROOT))
|
||||
|
||||
from unilabos.labware_manager.models import (
|
||||
AdapterInfo,
|
||||
GridInfo,
|
||||
LabwareDB,
|
||||
LabwareItem,
|
||||
MaterialInfo,
|
||||
TipInfo,
|
||||
TubeInfo,
|
||||
VolumeFunctions,
|
||||
WellInfo,
|
||||
)
|
||||
|
||||
# ---------- 路径常量 ----------
|
||||
_LABWARE_PY = Path(__file__).resolve().parents[1] / "devices" / "liquid_handling" / "prcxi" / "prcxi_labware.py"
|
||||
_REGISTRY_DIR = Path(__file__).resolve().parents[1] / "registry" / "resources" / "prcxi"
|
||||
_DB_PATH = Path(__file__).resolve().parent / "labware_db.json"
|
||||
|
||||
# YAML 文件名 → type 映射
|
||||
_YAML_MAP: Dict[str, str] = {
|
||||
"plates.yaml": "plate",
|
||||
"tip_racks.yaml": "tip_rack",
|
||||
"trash.yaml": "trash",
|
||||
"tube_racks.yaml": "tube_rack",
|
||||
"plate_adapters.yaml": "plate_adapter",
|
||||
}
|
||||
|
||||
# PRCXI_TEMPLATE_FACTORY_KINDS 中列出的函数名(include_in_template_matching=True)
|
||||
_TEMPLATE_FACTORY_NAMES = {
|
||||
"PRCXI_BioER_96_wellplate", "PRCXI_nest_1_troughplate",
|
||||
"PRCXI_BioRad_384_wellplate", "PRCXI_AGenBio_4_troughplate",
|
||||
"PRCXI_nest_12_troughplate", "PRCXI_CellTreat_96_wellplate",
|
||||
"PRCXI_10ul_eTips", "PRCXI_300ul_Tips",
|
||||
"PRCXI_PCR_Plate_200uL_nonskirted", "PRCXI_PCR_Plate_200uL_semiskirted",
|
||||
"PRCXI_PCR_Plate_200uL_skirted", "PRCXI_trash",
|
||||
"PRCXI_96_DeepWell", "PRCXI_EP_Adapter",
|
||||
"PRCXI_1250uL_Tips", "PRCXI_10uL_Tips",
|
||||
"PRCXI_1000uL_Tips", "PRCXI_200uL_Tips",
|
||||
"PRCXI_48_DeepWell",
|
||||
}
|
||||
|
||||
# template_kind 对应
|
||||
_TEMPLATE_KINDS: Dict[str, str] = {
|
||||
"PRCXI_BioER_96_wellplate": "plate",
|
||||
"PRCXI_nest_1_troughplate": "plate",
|
||||
"PRCXI_BioRad_384_wellplate": "plate",
|
||||
"PRCXI_AGenBio_4_troughplate": "plate",
|
||||
"PRCXI_nest_12_troughplate": "plate",
|
||||
"PRCXI_CellTreat_96_wellplate": "plate",
|
||||
"PRCXI_10ul_eTips": "tip_rack",
|
||||
"PRCXI_300ul_Tips": "tip_rack",
|
||||
"PRCXI_PCR_Plate_200uL_nonskirted": "plate",
|
||||
"PRCXI_PCR_Plate_200uL_semiskirted": "plate",
|
||||
"PRCXI_PCR_Plate_200uL_skirted": "plate",
|
||||
"PRCXI_trash": "trash",
|
||||
"PRCXI_96_DeepWell": "plate",
|
||||
"PRCXI_EP_Adapter": "tube_rack",
|
||||
"PRCXI_1250uL_Tips": "tip_rack",
|
||||
"PRCXI_10uL_Tips": "tip_rack",
|
||||
"PRCXI_1000uL_Tips": "tip_rack",
|
||||
"PRCXI_200uL_Tips": "tip_rack",
|
||||
"PRCXI_48_DeepWell": "plate",
|
||||
}
|
||||
|
||||
|
||||
def _load_registry_info() -> Dict[str, Dict[str, Any]]:
|
||||
"""读取所有 registry YAML 文件,返回 {function_name: {category, description}} 映射。"""
|
||||
info: Dict[str, Dict[str, Any]] = {}
|
||||
for fname, ltype in _YAML_MAP.items():
|
||||
fpath = _REGISTRY_DIR / fname
|
||||
if not fpath.exists():
|
||||
continue
|
||||
with open(fpath, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
for func_name, entry in data.items():
|
||||
info[func_name] = {
|
||||
"registry_category": entry.get("category", ["prcxi", ltype.replace("plate_adapter", "plate_adapters")]),
|
||||
"registry_description": entry.get("description", ""),
|
||||
}
|
||||
return info
|
||||
|
||||
|
||||
def _parse_ast_info() -> Dict[str, Dict[str, Any]]:
|
||||
"""AST 解析 prcxi_labware.py,提取每个工厂函数的 docstring 和 volume_function 参数。"""
|
||||
source = _LABWARE_PY.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
result: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.FunctionDef):
|
||||
continue
|
||||
fname = node.name
|
||||
if not fname.startswith("PRCXI_"):
|
||||
continue
|
||||
if fname.startswith("_"):
|
||||
continue
|
||||
|
||||
info: Dict[str, Any] = {"docstring": "", "volume_functions": None, "plate_type": None}
|
||||
|
||||
# docstring
|
||||
doc = ast.get_docstring(node)
|
||||
if doc:
|
||||
info["docstring"] = doc.strip()
|
||||
|
||||
# 搜索函数体中的 plate_type 赋值和 volume_function 参数
|
||||
func_source = ast.get_source_segment(source, node) or ""
|
||||
|
||||
# plate_type
|
||||
m = re.search(r'plate_type\s*=\s*["\']([^"\']+)["\']', func_source)
|
||||
if m:
|
||||
info["plate_type"] = m.group(1)
|
||||
|
||||
# volume_functions: 检查 compute_height_from_volume_rectangle
|
||||
if "compute_height_from_volume_rectangle" in func_source:
|
||||
# 提取 well_length 和 well_width
|
||||
vf: Dict[str, Any] = {"type": "rectangle"}
|
||||
# 尝试从 lambda 中提取
|
||||
wl_match = re.search(r'well_length\s*=\s*([\w_.]+)', func_source)
|
||||
ww_match = re.search(r'well_width\s*=\s*([\w_.]+)', func_source)
|
||||
if wl_match:
|
||||
vf["well_length_var"] = wl_match.group(1)
|
||||
if ww_match:
|
||||
vf["well_width_var"] = ww_match.group(1)
|
||||
info["volume_functions"] = vf
|
||||
|
||||
result[fname] = info
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _probe_factory(factory_func) -> Any:
|
||||
"""实例化工厂函数获取 resource 对象。"""
|
||||
if factory_func.__name__ == "PRCXI_trash":
|
||||
return factory_func()
|
||||
return factory_func("__probe__")
|
||||
|
||||
|
||||
def _get_size(resource, attr: str) -> float:
|
||||
"""获取 PLR Resource 的尺寸(兼容 size_x 和 _size_x)。"""
|
||||
val = getattr(resource, attr, None)
|
||||
if val is None:
|
||||
val = getattr(resource, f"_{attr}", None)
|
||||
if val is None:
|
||||
val = getattr(resource, f"get_{attr}", lambda: 0)()
|
||||
return float(val or 0)
|
||||
|
||||
|
||||
def _extract_grid_from_children(resource) -> Optional[Dict[str, Any]]:
|
||||
"""从 resource.children 提取网格信息。"""
|
||||
children = getattr(resource, "children", None) or []
|
||||
if not children:
|
||||
return None
|
||||
|
||||
# 获取 num_items_x, num_items_y
|
||||
num_x = getattr(resource, "num_items_x", None)
|
||||
num_y = getattr(resource, "num_items_y", None)
|
||||
if num_x is None or num_y is None:
|
||||
return None
|
||||
|
||||
c0 = children[0]
|
||||
loc0 = getattr(c0, "location", None)
|
||||
dx = loc0.x if loc0 else 0.0
|
||||
dy_raw = loc0.y if loc0 else 0.0 # 这是 PLR 布局后的位置,不是输入参数
|
||||
dz = loc0.z if loc0 else 0.0
|
||||
|
||||
# 推算 item_dx, item_dy
|
||||
item_dx = 9.0
|
||||
item_dy = 9.0
|
||||
if len(children) > 1:
|
||||
c1 = children[1]
|
||||
loc1 = getattr(c1, "location", None)
|
||||
if loc1 and loc0:
|
||||
diff_x = abs(loc1.x - loc0.x)
|
||||
diff_y = abs(loc1.y - loc0.y)
|
||||
if diff_x > 0.1:
|
||||
item_dx = diff_x
|
||||
if diff_y > 0.1:
|
||||
item_dy = diff_y
|
||||
|
||||
# 如果 num_items_y > 1 且 num_items_x > 1, 找列间距
|
||||
if int(num_y) > 1 and int(num_x) > 1 and len(children) >= int(num_y) + 1:
|
||||
cn = children[int(num_y)]
|
||||
locn = getattr(cn, "location", None)
|
||||
if locn and loc0:
|
||||
col_diff = abs(locn.x - loc0.x)
|
||||
row_diff = abs(children[1].location.y - loc0.y) if len(children) > 1 else item_dy
|
||||
if col_diff > 0.1:
|
||||
item_dx = col_diff
|
||||
if row_diff > 0.1:
|
||||
item_dy = row_diff
|
||||
|
||||
# PLR create_ordered_items_2d 的 Y 轴排列是倒序的:
|
||||
# child[0].y = dy_param + (num_y - 1) * item_dy (最上面一行)
|
||||
# 因此反推原始 dy 参数:
|
||||
dy = dy_raw - (int(num_y) - 1) * item_dy
|
||||
|
||||
return {
|
||||
"num_items_x": int(num_x),
|
||||
"num_items_y": int(num_y),
|
||||
"dx": round(dx, 4),
|
||||
"dy": round(dy, 4),
|
||||
"dz": round(dz, 4),
|
||||
"item_dx": round(item_dx, 4),
|
||||
"item_dy": round(item_dy, 4),
|
||||
}
|
||||
|
||||
|
||||
def _extract_well_info(child) -> Dict[str, Any]:
|
||||
"""从 Well/TipSpot/Tube 子对象提取信息。"""
|
||||
# material_z_thickness 在 PLR 中如果未设置会抛 NotImplementedError
|
||||
mzt = None
|
||||
try:
|
||||
mzt = child.material_z_thickness
|
||||
except (NotImplementedError, AttributeError):
|
||||
mzt = getattr(child, "_material_z_thickness", None)
|
||||
|
||||
return {
|
||||
"size_x": round(_get_size(child, "size_x"), 4),
|
||||
"size_y": round(_get_size(child, "size_y"), 4),
|
||||
"size_z": round(_get_size(child, "size_z"), 4),
|
||||
"max_volume": getattr(child, "max_volume", None),
|
||||
"bottom_type": getattr(child, "bottom_type", None),
|
||||
"cross_section_type": getattr(child, "cross_section_type", None),
|
||||
"material_z_thickness": mzt,
|
||||
}
|
||||
|
||||
|
||||
def import_from_code() -> LabwareDB:
|
||||
"""执行完整的导入流程,返回 LabwareDB 对象。"""
|
||||
# 1. 加载 registry 信息
|
||||
reg_info = _load_registry_info()
|
||||
|
||||
# 2. AST 解析源码
|
||||
ast_info = _parse_ast_info()
|
||||
|
||||
# 3. 导入工厂模块(通过包路径避免 relative import 问题)
|
||||
import importlib
|
||||
mod = importlib.import_module("unilabos.devices.liquid_handling.prcxi.prcxi_labware")
|
||||
|
||||
# 4. 获取 PRCXI_TEMPLATE_FACTORY_KINDS 列出的函数
|
||||
factory_kinds = getattr(mod, "PRCXI_TEMPLATE_FACTORY_KINDS", [])
|
||||
template_func_names = {f.__name__ for f, _k in factory_kinds}
|
||||
|
||||
# 5. 收集所有 PRCXI_ 开头的工厂函数
|
||||
all_factories: List[Tuple[str, Any]] = []
|
||||
for attr_name in dir(mod):
|
||||
if attr_name.startswith("PRCXI_") and not attr_name.startswith("_"):
|
||||
obj = getattr(mod, attr_name)
|
||||
if callable(obj) and not isinstance(obj, type):
|
||||
all_factories.append((attr_name, obj))
|
||||
|
||||
# 按源码行号排序
|
||||
all_factories.sort(key=lambda x: getattr(x[1], "__code__", None) and x[1].__code__.co_firstlineno or 0)
|
||||
|
||||
items: List[LabwareItem] = []
|
||||
|
||||
for func_name, factory in all_factories:
|
||||
try:
|
||||
resource = _probe_factory(factory)
|
||||
except Exception as e:
|
||||
print(f"跳过 {func_name}: {e}")
|
||||
continue
|
||||
|
||||
# 确定类型
|
||||
type_name = "plate"
|
||||
class_name = type(resource).__name__
|
||||
if "TipRack" in class_name:
|
||||
type_name = "tip_rack"
|
||||
elif "Trash" in class_name:
|
||||
type_name = "trash"
|
||||
elif "TubeRack" in class_name:
|
||||
type_name = "tube_rack"
|
||||
elif "PlateAdapter" in class_name:
|
||||
type_name = "plate_adapter"
|
||||
|
||||
# material_info
|
||||
state = getattr(resource, "_unilabos_state", {}) or {}
|
||||
mat = state.get("Material", {})
|
||||
mat_info = MaterialInfo(
|
||||
uuid=mat.get("uuid", ""),
|
||||
Code=mat.get("Code", ""),
|
||||
Name=mat.get("Name", ""),
|
||||
materialEnum=mat.get("materialEnum"),
|
||||
SupplyType=mat.get("SupplyType"),
|
||||
)
|
||||
|
||||
# AST 信息
|
||||
ast_data = ast_info.get(func_name, {})
|
||||
docstring = ast_data.get("docstring", "")
|
||||
plate_type = ast_data.get("plate_type")
|
||||
|
||||
# Registry 信息
|
||||
reg = reg_info.get(func_name, {})
|
||||
registry_category = reg.get("registry_category", ["prcxi", _type_to_yaml_subcategory(type_name)])
|
||||
registry_description = reg.get("registry_description", f'{mat_info.Name} (Code: {mat_info.Code})')
|
||||
|
||||
# 构建 item
|
||||
item = LabwareItem(
|
||||
id=func_name.lower().replace("prcxi_", "")[:8] or func_name[:8],
|
||||
type=type_name,
|
||||
function_name=func_name,
|
||||
docstring=docstring,
|
||||
size_x=round(_get_size(resource, "size_x"), 4),
|
||||
size_y=round(_get_size(resource, "size_y"), 4),
|
||||
size_z=round(_get_size(resource, "size_z"), 4),
|
||||
model=getattr(resource, "model", None),
|
||||
category=getattr(resource, "category", type_name),
|
||||
plate_type=plate_type,
|
||||
material_info=mat_info,
|
||||
registry_category=registry_category,
|
||||
registry_description=registry_description,
|
||||
include_in_template_matching=func_name in template_func_names,
|
||||
template_kind=_TEMPLATE_KINDS.get(func_name),
|
||||
)
|
||||
|
||||
# 提取子项信息
|
||||
children = getattr(resource, "children", None) or []
|
||||
grid_data = _extract_grid_from_children(resource)
|
||||
|
||||
if type_name == "plate" and children:
|
||||
if grid_data:
|
||||
item.grid = GridInfo(**grid_data)
|
||||
c0 = children[0]
|
||||
well_data = _extract_well_info(c0)
|
||||
bt = well_data.get("bottom_type")
|
||||
if bt is not None:
|
||||
bt = bt.name if hasattr(bt, "name") else str(bt)
|
||||
else:
|
||||
bt = "FLAT"
|
||||
cst = well_data.get("cross_section_type")
|
||||
if cst is not None:
|
||||
cst = cst.name if hasattr(cst, "name") else str(cst)
|
||||
else:
|
||||
cst = "CIRCLE"
|
||||
item.well = WellInfo(
|
||||
size_x=well_data["size_x"],
|
||||
size_y=well_data["size_y"],
|
||||
size_z=well_data["size_z"],
|
||||
max_volume=well_data.get("max_volume"),
|
||||
bottom_type=bt,
|
||||
cross_section_type=cst,
|
||||
material_z_thickness=well_data.get("material_z_thickness"),
|
||||
)
|
||||
# volume_functions
|
||||
vf = ast_data.get("volume_functions")
|
||||
if vf:
|
||||
# 需要实际获取 well 尺寸作为 volume_function 参数
|
||||
item.volume_functions = VolumeFunctions(
|
||||
type="rectangle",
|
||||
well_length=well_data["size_x"],
|
||||
well_width=well_data["size_y"],
|
||||
)
|
||||
|
||||
elif type_name == "tip_rack" and children:
|
||||
if grid_data:
|
||||
item.grid = GridInfo(**grid_data)
|
||||
c0 = children[0]
|
||||
tip_obj = getattr(c0, "tip", None)
|
||||
tip_volume = 300.0
|
||||
tip_length = 60.0
|
||||
tip_depth = 51.0
|
||||
tip_filter = False
|
||||
if tip_obj:
|
||||
tip_volume = getattr(tip_obj, "maximal_volume", 300.0)
|
||||
tip_length = getattr(tip_obj, "total_tip_length", 60.0)
|
||||
tip_depth = getattr(tip_obj, "fitting_depth", 51.0)
|
||||
tip_filter = getattr(tip_obj, "has_filter", False)
|
||||
item.tip = TipInfo(
|
||||
spot_size_x=round(_get_size(c0, "size_x"), 4),
|
||||
spot_size_y=round(_get_size(c0, "size_y"), 4),
|
||||
spot_size_z=round(_get_size(c0, "size_z"), 4),
|
||||
tip_volume=tip_volume,
|
||||
tip_length=tip_length,
|
||||
tip_fitting_depth=tip_depth,
|
||||
has_filter=tip_filter,
|
||||
)
|
||||
# 计算 tip_above_rack_length = tip_length - (size_z - dz)
|
||||
if grid_data:
|
||||
_dz = grid_data.get("dz", 0.0)
|
||||
_above = tip_length - (item.size_z - _dz)
|
||||
item.tip.tip_above_rack_length = round(_above, 4) if _above > 0 else None
|
||||
|
||||
elif type_name == "tube_rack" and children:
|
||||
if grid_data:
|
||||
item.grid = GridInfo(**grid_data)
|
||||
c0 = children[0]
|
||||
item.tube = TubeInfo(
|
||||
size_x=round(_get_size(c0, "size_x"), 4),
|
||||
size_y=round(_get_size(c0, "size_y"), 4),
|
||||
size_z=round(_get_size(c0, "size_z"), 4),
|
||||
max_volume=getattr(c0, "max_volume", 1500.0) or 1500.0,
|
||||
)
|
||||
|
||||
elif type_name == "plate_adapter":
|
||||
# 提取 adapter 参数
|
||||
ahx = getattr(resource, "adapter_hole_size_x", 127.76)
|
||||
ahy = getattr(resource, "adapter_hole_size_y", 85.48)
|
||||
ahz = getattr(resource, "adapter_hole_size_z", 10.0)
|
||||
adx = getattr(resource, "dx", None)
|
||||
ady = getattr(resource, "dy", None)
|
||||
adz = getattr(resource, "dz", 0.0)
|
||||
item.adapter = AdapterInfo(
|
||||
adapter_hole_size_x=ahx,
|
||||
adapter_hole_size_y=ahy,
|
||||
adapter_hole_size_z=ahz,
|
||||
dx=adx,
|
||||
dy=ady,
|
||||
dz=adz,
|
||||
)
|
||||
|
||||
items.append(item)
|
||||
|
||||
return LabwareDB(items=items)
|
||||
|
||||
|
||||
def _type_to_yaml_subcategory(type_name: str) -> str:
|
||||
mapping = {
|
||||
"plate": "plates",
|
||||
"tip_rack": "tip_racks",
|
||||
"trash": "trash",
|
||||
"tube_rack": "tube_racks",
|
||||
"plate_adapter": "plate_adapters",
|
||||
}
|
||||
return mapping.get(type_name, type_name)
|
||||
|
||||
|
||||
def save_db(db: LabwareDB, path: Optional[Path] = None) -> Path:
|
||||
"""保存 LabwareDB 到 JSON 文件。"""
|
||||
out = path or _DB_PATH
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
json.dump(db.model_dump(), f, ensure_ascii=False, indent=2)
|
||||
return out
|
||||
|
||||
|
||||
def load_db(path: Optional[Path] = None) -> LabwareDB:
|
||||
"""从 JSON 文件加载 LabwareDB。"""
|
||||
src = path or _DB_PATH
|
||||
if not src.exists():
|
||||
return LabwareDB()
|
||||
with open(src, "r", encoding="utf-8") as f:
|
||||
return LabwareDB(**json.load(f))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
db = import_from_code()
|
||||
out = save_db(db)
|
||||
print(f"已导入 {len(db.items)} 个耗材 → {out}")
|
||||
for item in db.items:
|
||||
print(f" [{item.type:14s}] {item.function_name}")
|
||||
1316
unilabos/labware_manager/labware_db.json
Normal file
1316
unilabos/labware_manager/labware_db.json
Normal file
File diff suppressed because it is too large
Load Diff
126
unilabos/labware_manager/models.py
Normal file
126
unilabos/labware_manager/models.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Pydantic 数据模型,描述所有 PRCXI 耗材类型的 JSON 结构。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as _uuid
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MaterialInfo(BaseModel):
|
||||
uuid: str = ""
|
||||
Code: str = ""
|
||||
Name: str = ""
|
||||
materialEnum: Optional[int] = None
|
||||
SupplyType: Optional[int] = None
|
||||
|
||||
|
||||
class GridInfo(BaseModel):
|
||||
"""孔位网格排列参数"""
|
||||
num_items_x: int = 12
|
||||
num_items_y: int = 8
|
||||
dx: float = 0.0
|
||||
dy: float = 0.0
|
||||
dz: float = 0.0
|
||||
item_dx: float = 9.0
|
||||
item_dy: float = 9.0
|
||||
|
||||
|
||||
class WellInfo(BaseModel):
|
||||
"""孔参数 (Plate)"""
|
||||
size_x: float = 8.0
|
||||
size_y: float = 8.0
|
||||
size_z: float = 10.0
|
||||
max_volume: Optional[float] = None
|
||||
bottom_type: str = "FLAT" # V / U / FLAT
|
||||
cross_section_type: str = "CIRCLE" # CIRCLE / RECTANGLE
|
||||
material_z_thickness: Optional[float] = None
|
||||
|
||||
|
||||
class VolumeFunctions(BaseModel):
|
||||
"""体积-高度计算函数参数 (矩形 well)"""
|
||||
type: str = "rectangle"
|
||||
well_length: float = 0.0
|
||||
well_width: float = 0.0
|
||||
|
||||
|
||||
class TipInfo(BaseModel):
|
||||
"""枪头参数 (TipRack)"""
|
||||
spot_size_x: float = 7.0
|
||||
spot_size_y: float = 7.0
|
||||
spot_size_z: float = 0.0
|
||||
tip_volume: float = 300.0
|
||||
tip_length: float = 60.0
|
||||
tip_fitting_depth: float = 51.0
|
||||
tip_above_rack_length: Optional[float] = None
|
||||
has_filter: bool = False
|
||||
|
||||
|
||||
class TubeInfo(BaseModel):
|
||||
"""管参数 (TubeRack)"""
|
||||
size_x: float = 10.6
|
||||
size_y: float = 10.6
|
||||
size_z: float = 40.0
|
||||
max_volume: float = 1500.0
|
||||
|
||||
|
||||
class AdapterInfo(BaseModel):
|
||||
"""适配器参数 (PlateAdapter)"""
|
||||
adapter_hole_size_x: float = 127.76
|
||||
adapter_hole_size_y: float = 85.48
|
||||
adapter_hole_size_z: float = 10.0
|
||||
dx: Optional[float] = None
|
||||
dy: Optional[float] = None
|
||||
dz: float = 0.0
|
||||
|
||||
|
||||
LabwareType = Literal["plate", "tip_rack", "trash", "tube_rack", "plate_adapter"]
|
||||
|
||||
|
||||
class LabwareItem(BaseModel):
|
||||
"""一个耗材条目的完整 JSON 表示"""
|
||||
|
||||
id: str = Field(default_factory=lambda: _uuid.uuid4().hex[:8])
|
||||
type: LabwareType = "plate"
|
||||
function_name: str = ""
|
||||
docstring: str = ""
|
||||
|
||||
# 物理尺寸
|
||||
size_x: float = 127.0
|
||||
size_y: float = 85.0
|
||||
size_z: float = 20.0
|
||||
model: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
plate_type: Optional[str] = None # non-skirted / semi-skirted / skirted
|
||||
|
||||
# 材料信息
|
||||
material_info: MaterialInfo = Field(default_factory=MaterialInfo)
|
||||
|
||||
# Registry 字段
|
||||
registry_category: List[str] = Field(default_factory=lambda: ["prcxi", "plates"])
|
||||
registry_description: str = ""
|
||||
|
||||
# Plate 特有
|
||||
grid: Optional[GridInfo] = None
|
||||
well: Optional[WellInfo] = None
|
||||
volume_functions: Optional[VolumeFunctions] = None
|
||||
|
||||
# TipRack 特有
|
||||
tip: Optional[TipInfo] = None
|
||||
|
||||
# TubeRack 特有
|
||||
tube: Optional[TubeInfo] = None
|
||||
|
||||
# PlateAdapter 特有
|
||||
adapter: Optional[AdapterInfo] = None
|
||||
|
||||
# 模板匹配
|
||||
include_in_template_matching: bool = False
|
||||
template_kind: Optional[str] = None
|
||||
|
||||
|
||||
class LabwareDB(BaseModel):
|
||||
"""整个 labware_db.json 的结构"""
|
||||
version: str = "1.0"
|
||||
items: List[LabwareItem] = Field(default_factory=list)
|
||||
292
unilabos/labware_manager/static/form_handler.js
Normal file
292
unilabos/labware_manager/static/form_handler.js
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* form_handler.js — 动态表单逻辑 + 实时预览
|
||||
*/
|
||||
|
||||
// 根据类型显示/隐藏对应的表单段
|
||||
function onTypeChange() {
|
||||
const type = document.getElementById('f-type').value;
|
||||
const sections = {
|
||||
grid: ['plate', 'tip_rack', 'tube_rack'],
|
||||
well: ['plate'],
|
||||
tip: ['tip_rack'],
|
||||
tube: ['tube_rack'],
|
||||
adapter: ['plate_adapter'],
|
||||
};
|
||||
|
||||
for (const [sec, types] of Object.entries(sections)) {
|
||||
const el = document.getElementById('section-' + sec);
|
||||
if (el) el.style.display = types.includes(type) ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// plate_type 行只对 plate 显示
|
||||
const ptRow = document.getElementById('row-plate_type');
|
||||
if (ptRow) ptRow.style.display = type === 'plate' ? 'block' : 'none';
|
||||
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// 从表单收集数据
|
||||
function collectFormData() {
|
||||
const g = id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return null;
|
||||
if (el.type === 'checkbox') return el.checked;
|
||||
if (el.type === 'number') return el.value === '' ? null : parseFloat(el.value);
|
||||
return el.value || null;
|
||||
};
|
||||
|
||||
const type = g('f-type');
|
||||
|
||||
const data = {
|
||||
type: type,
|
||||
function_name: g('f-function_name') || 'PRCXI_new',
|
||||
model: g('f-model'),
|
||||
docstring: g('f-docstring') || '',
|
||||
plate_type: type === 'plate' ? g('f-plate_type') : null,
|
||||
size_x: g('f-size_x') || 127,
|
||||
size_y: g('f-size_y') || 85,
|
||||
size_z: g('f-size_z') || 20,
|
||||
material_info: {
|
||||
uuid: g('f-mi_uuid') || '',
|
||||
Code: g('f-mi_code') || '',
|
||||
Name: g('f-mi_name') || '',
|
||||
materialEnum: g('f-mi_menum'),
|
||||
SupplyType: g('f-mi_stype'),
|
||||
},
|
||||
registry_category: (g('f-reg_cat') || 'prcxi,plates').split(',').map(s => s.trim()),
|
||||
registry_description: g('f-reg_desc') || '',
|
||||
include_in_template_matching: g('f-in_tpl') || false,
|
||||
template_kind: g('f-tpl_kind') || null,
|
||||
grid: null,
|
||||
well: null,
|
||||
tip: null,
|
||||
tube: null,
|
||||
adapter: null,
|
||||
volume_functions: null,
|
||||
};
|
||||
|
||||
// Grid
|
||||
if (['plate', 'tip_rack', 'tube_rack'].includes(type)) {
|
||||
data.grid = {
|
||||
num_items_x: g('f-grid_nx') || 12,
|
||||
num_items_y: g('f-grid_ny') || 8,
|
||||
dx: g('f-grid_dx') || 0,
|
||||
dy: g('f-grid_dy') || 0,
|
||||
dz: g('f-grid_dz') || 0,
|
||||
item_dx: g('f-grid_idx') || 9,
|
||||
item_dy: g('f-grid_idy') || 9,
|
||||
};
|
||||
}
|
||||
|
||||
// Well
|
||||
if (type === 'plate') {
|
||||
data.well = {
|
||||
size_x: g('f-well_sx') || 8,
|
||||
size_y: g('f-well_sy') || 8,
|
||||
size_z: g('f-well_sz') || 10,
|
||||
max_volume: g('f-well_vol'),
|
||||
material_z_thickness: g('f-well_mzt'),
|
||||
bottom_type: g('f-well_bt') || 'FLAT',
|
||||
cross_section_type: g('f-well_cs') || 'CIRCLE',
|
||||
};
|
||||
if (g('f-has_vf')) {
|
||||
data.volume_functions = {
|
||||
type: 'rectangle',
|
||||
well_length: data.well.size_x,
|
||||
well_width: data.well.size_y,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Tip
|
||||
if (type === 'tip_rack') {
|
||||
data.tip = {
|
||||
spot_size_x: g('f-tip_sx') || 7,
|
||||
spot_size_y: g('f-tip_sy') || 7,
|
||||
spot_size_z: g('f-tip_sz') || 0,
|
||||
tip_volume: g('f-tip_vol') || 300,
|
||||
tip_length: g('f-tip_len') || 60,
|
||||
tip_fitting_depth: g('f-tip_dep') || 51,
|
||||
tip_above_rack_length: g('f-tip_above'),
|
||||
has_filter: g('f-tip_filter') || false,
|
||||
};
|
||||
}
|
||||
|
||||
// Tube
|
||||
if (type === 'tube_rack') {
|
||||
data.tube = {
|
||||
size_x: g('f-tube_sx') || 10.6,
|
||||
size_y: g('f-tube_sy') || 10.6,
|
||||
size_z: g('f-tube_sz') || 40,
|
||||
max_volume: g('f-tube_vol') || 1500,
|
||||
};
|
||||
}
|
||||
|
||||
// Adapter
|
||||
if (type === 'plate_adapter') {
|
||||
data.adapter = {
|
||||
adapter_hole_size_x: g('f-adp_hsx') || 127.76,
|
||||
adapter_hole_size_y: g('f-adp_hsy') || 85.48,
|
||||
adapter_hole_size_z: g('f-adp_hsz') || 10,
|
||||
dx: g('f-adp_dx'),
|
||||
dy: g('f-adp_dy'),
|
||||
dz: g('f-adp_dz') || 0,
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// 实时预览 (debounce)
|
||||
let _previewTimer = null;
|
||||
function updatePreview() {
|
||||
if (_previewTimer) clearTimeout(_previewTimer);
|
||||
_previewTimer = setTimeout(() => {
|
||||
const data = collectFormData();
|
||||
const topEl = document.getElementById('svg-topdown');
|
||||
const sideEl = document.getElementById('svg-side');
|
||||
if (topEl) renderTopDown(topEl, data);
|
||||
if (sideEl) renderSideProfile(sideEl, data);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// 给所有表单元素绑定 input 事件
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('labware-form');
|
||||
if (!form) return;
|
||||
form.addEventListener('input', updatePreview);
|
||||
form.addEventListener('change', updatePreview);
|
||||
|
||||
// tip_above_rack_length 与 dz 互算
|
||||
// 公式: tip_length = tip_above_rack_length + size_z - dz
|
||||
// 规则: 填 tip_above → 自动算 dz;填 dz → 自动算 tip_above
|
||||
// 改 size_z / tip_length → 优先重算 tip_above(若有值),否则算 dz
|
||||
|
||||
function _getVal(id) {
|
||||
const el = document.getElementById(id);
|
||||
return (el && el.value !== '') ? parseFloat(el.value) : null;
|
||||
}
|
||||
function _setVal(id, v) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = Math.round(v * 1000) / 1000;
|
||||
}
|
||||
|
||||
function autoCalcTipAbove(changedId) {
|
||||
const typeEl = document.getElementById('f-type');
|
||||
if (!typeEl || typeEl.value !== 'tip_rack') return;
|
||||
|
||||
const tipLen = _getVal('f-tip_len');
|
||||
const sizeZ = _getVal('f-size_z');
|
||||
const dz = _getVal('f-grid_dz');
|
||||
const above = _getVal('f-tip_above');
|
||||
|
||||
// 需要 tip_length 和 size_z 才能计算
|
||||
if (tipLen == null || sizeZ == null) return;
|
||||
|
||||
if (changedId === 'f-tip_above') {
|
||||
// 用户填了 tip_above → 算 dz
|
||||
if (above != null) _setVal('f-grid_dz', above + sizeZ - tipLen);
|
||||
} else if (changedId === 'f-grid_dz') {
|
||||
// 用户填了 dz → 算 tip_above
|
||||
if (dz != null) _setVal('f-tip_above', tipLen - sizeZ + dz);
|
||||
} else {
|
||||
// size_z 或 tip_length 变了 → 优先重算 tip_above(若已有值或 dz 已有值)
|
||||
if (dz != null) {
|
||||
_setVal('f-tip_above', tipLen - sizeZ + dz);
|
||||
} else if (above != null) {
|
||||
_setVal('f-grid_dz', above + sizeZ - tipLen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定 input 事件
|
||||
for (const id of ['f-tip_len', 'f-size_z', 'f-grid_dz', 'f-tip_above']) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.addEventListener('input', () => autoCalcTipAbove(id));
|
||||
}
|
||||
|
||||
// 编辑已有 tip_rack 条目时自动补算 tip_above_rack_length
|
||||
const typeEl = document.getElementById('f-type');
|
||||
if (typeEl && typeEl.value === 'tip_rack') {
|
||||
autoCalcTipAbove('f-grid_dz');
|
||||
}
|
||||
});
|
||||
|
||||
// 自动居中:根据板尺寸和孔阵列参数计算 dx/dy
|
||||
function autoCenter() {
|
||||
const g = id => { const el = document.getElementById(id); return el && el.value !== '' ? parseFloat(el.value) : 0; };
|
||||
|
||||
const sizeX = g('f-size_x') || 127;
|
||||
const sizeY = g('f-size_y') || 85;
|
||||
const nx = g('f-grid_nx') || 1;
|
||||
const ny = g('f-grid_ny') || 1;
|
||||
const itemDx = g('f-grid_idx') || 9;
|
||||
const itemDy = g('f-grid_idy') || 9;
|
||||
|
||||
// 根据当前耗材类型确定子元素尺寸
|
||||
const type = document.getElementById('f-type').value;
|
||||
let childSx = 0, childSy = 0;
|
||||
if (type === 'plate') {
|
||||
childSx = g('f-well_sx') || 8;
|
||||
childSy = g('f-well_sy') || 8;
|
||||
} else if (type === 'tip_rack') {
|
||||
childSx = g('f-tip_sx') || 7;
|
||||
childSy = g('f-tip_sy') || 7;
|
||||
} else if (type === 'tube_rack') {
|
||||
childSx = g('f-tube_sx') || 10.6;
|
||||
childSy = g('f-tube_sy') || 10.6;
|
||||
}
|
||||
|
||||
// dx = (板宽 - 孔阵列总占宽) / 2
|
||||
const dx = (sizeX - (nx - 1) * itemDx - childSx) / 2;
|
||||
const dy = (sizeY - (ny - 1) * itemDy - childSy) / 2;
|
||||
|
||||
const elDx = document.getElementById('f-grid_dx');
|
||||
const elDy = document.getElementById('f-grid_dy');
|
||||
if (elDx) elDx.value = Math.round(dx * 100) / 100;
|
||||
if (elDy) elDy.value = Math.round(dy * 100) / 100;
|
||||
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
// 保存
|
||||
function showMsg(text, ok) {
|
||||
const el = document.getElementById('status-msg');
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.className = 'status-msg ' + (ok ? 'msg-ok' : 'msg-err');
|
||||
el.style.display = 'block';
|
||||
setTimeout(() => el.style.display = 'none', 4000);
|
||||
}
|
||||
|
||||
async function saveForm() {
|
||||
const data = collectFormData();
|
||||
|
||||
let url, method;
|
||||
if (typeof IS_NEW !== 'undefined' && IS_NEW) {
|
||||
url = '/api/labware';
|
||||
method = 'POST';
|
||||
} else {
|
||||
url = '/api/labware/' + (typeof ITEM_ID !== 'undefined' ? ITEM_ID : '');
|
||||
method = 'PUT';
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
showMsg('保存成功', true);
|
||||
if (IS_NEW) {
|
||||
setTimeout(() => location.href = '/labware/' + data.function_name, 500);
|
||||
}
|
||||
} else {
|
||||
showMsg('保存失败: ' + JSON.stringify(d), false);
|
||||
}
|
||||
} catch (e) {
|
||||
showMsg('请求错误: ' + e.message, false);
|
||||
}
|
||||
}
|
||||
450
unilabos/labware_manager/static/labware_viz.js
Normal file
450
unilabos/labware_manager/static/labware_viz.js
Normal file
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* labware_viz.js — PRCXI 耗材 SVG 2D 可视化渲染引擎
|
||||
*
|
||||
* renderTopDown(container, itemData) — 俯视图
|
||||
* renderSideProfile(container, itemData) — 侧面截面图
|
||||
*/
|
||||
|
||||
const TYPE_COLORS = {
|
||||
plate: '#3b82f6',
|
||||
tip_rack: '#10b981',
|
||||
tube_rack: '#f59e0b',
|
||||
trash: '#ef4444',
|
||||
plate_adapter: '#8b5cf6',
|
||||
};
|
||||
|
||||
function _svgNS() { return 'http://www.w3.org/2000/svg'; }
|
||||
|
||||
function _makeSVG(w, h) {
|
||||
const svg = document.createElementNS(_svgNS(), 'svg');
|
||||
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
||||
svg.setAttribute('width', '100%');
|
||||
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||
svg.style.background = '#fff';
|
||||
return svg;
|
||||
}
|
||||
|
||||
function _rect(svg, x, y, w, h, fill, stroke, rx) {
|
||||
const r = document.createElementNS(_svgNS(), 'rect');
|
||||
r.setAttribute('x', x); r.setAttribute('y', y);
|
||||
r.setAttribute('width', w); r.setAttribute('height', h);
|
||||
r.setAttribute('fill', fill || 'none');
|
||||
r.setAttribute('stroke', stroke || '#333');
|
||||
r.setAttribute('stroke-width', '0.5');
|
||||
if (rx) r.setAttribute('rx', rx);
|
||||
svg.appendChild(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
function _circle(svg, cx, cy, r, fill, stroke) {
|
||||
const c = document.createElementNS(_svgNS(), 'circle');
|
||||
c.setAttribute('cx', cx); c.setAttribute('cy', cy);
|
||||
c.setAttribute('r', r);
|
||||
c.setAttribute('fill', fill || 'none');
|
||||
c.setAttribute('stroke', stroke || '#333');
|
||||
c.setAttribute('stroke-width', '0.4');
|
||||
svg.appendChild(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
function _text(svg, x, y, txt, size, anchor, fill) {
|
||||
const t = document.createElementNS(_svgNS(), 'text');
|
||||
t.setAttribute('x', x); t.setAttribute('y', y);
|
||||
t.setAttribute('font-size', size || '3');
|
||||
t.setAttribute('text-anchor', anchor || 'middle');
|
||||
t.setAttribute('fill', fill || '#666');
|
||||
t.setAttribute('font-family', 'sans-serif');
|
||||
t.textContent = txt;
|
||||
svg.appendChild(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
function _line(svg, x1, y1, x2, y2, stroke, dash) {
|
||||
const l = document.createElementNS(_svgNS(), 'line');
|
||||
l.setAttribute('x1', x1); l.setAttribute('y1', y1);
|
||||
l.setAttribute('x2', x2); l.setAttribute('y2', y2);
|
||||
l.setAttribute('stroke', stroke || '#999');
|
||||
l.setAttribute('stroke-width', '0.3');
|
||||
if (dash) l.setAttribute('stroke-dasharray', dash);
|
||||
svg.appendChild(l);
|
||||
return l;
|
||||
}
|
||||
|
||||
function _title(el, txt) {
|
||||
const t = document.createElementNS(_svgNS(), 'title');
|
||||
t.textContent = txt;
|
||||
el.appendChild(t);
|
||||
}
|
||||
|
||||
// ==================== 俯视图 ====================
|
||||
function renderTopDown(container, data) {
|
||||
container.innerHTML = '';
|
||||
if (!data) return;
|
||||
|
||||
const pad = 18;
|
||||
const sx = data.size_x || 127;
|
||||
const sy = data.size_y || 85;
|
||||
const w = sx + pad * 2;
|
||||
const h = sy + pad * 2;
|
||||
const svg = _makeSVG(w, h);
|
||||
|
||||
const color = TYPE_COLORS[data.type] || '#3b82f6';
|
||||
const lightColor = color + '22';
|
||||
|
||||
// 板子外轮廓
|
||||
_rect(svg, pad, pad, sx, sy, lightColor, color, 3);
|
||||
|
||||
// 尺寸标注
|
||||
_text(svg, pad + sx / 2, pad - 4, `${sx} mm`, '3.5', 'middle', '#333');
|
||||
// Y 尺寸 (竖直)
|
||||
const yt = document.createElementNS(_svgNS(), 'text');
|
||||
yt.setAttribute('x', pad - 5);
|
||||
yt.setAttribute('y', pad + sy / 2);
|
||||
yt.setAttribute('font-size', '3.5');
|
||||
yt.setAttribute('text-anchor', 'middle');
|
||||
yt.setAttribute('fill', '#333');
|
||||
yt.setAttribute('font-family', 'sans-serif');
|
||||
yt.setAttribute('transform', `rotate(-90, ${pad - 5}, ${pad + sy / 2})`);
|
||||
yt.textContent = `${sy} mm`;
|
||||
svg.appendChild(yt);
|
||||
|
||||
const grid = data.grid;
|
||||
const well = data.well;
|
||||
const tip = data.tip;
|
||||
const tube = data.tube;
|
||||
|
||||
if (grid && (well || tip || tube)) {
|
||||
const nx = grid.num_items_x || 1;
|
||||
const ny = grid.num_items_y || 1;
|
||||
const dx = grid.dx || 0;
|
||||
const dy = grid.dy || 0;
|
||||
const idx = grid.item_dx || 9;
|
||||
const idy = grid.item_dy || 9;
|
||||
|
||||
const child = well || tip || tube;
|
||||
const csx = child.size_x || child.spot_size_x || 8;
|
||||
const csy = child.size_y || child.spot_size_y || 8;
|
||||
|
||||
const isCircle = well ? (well.cross_section_type === 'CIRCLE') : (!!tip);
|
||||
|
||||
// 行列标签
|
||||
for (let col = 0; col < nx; col++) {
|
||||
const cx = pad + dx + csx / 2 + col * idx;
|
||||
_text(svg, cx, pad + sy + 5, String(col + 1), '2.5', 'middle', '#999');
|
||||
}
|
||||
const rowLabels = 'ABCDEFGHIJKLMNOP';
|
||||
for (let row = 0; row < ny; row++) {
|
||||
const cy = pad + dy + csy / 2 + row * idy;
|
||||
_text(svg, pad - 4, cy + 1, rowLabels[row] || String(row), '2.5', 'middle', '#999');
|
||||
}
|
||||
|
||||
// 绘制孔位
|
||||
for (let col = 0; col < nx; col++) {
|
||||
for (let row = 0; row < ny; row++) {
|
||||
const cx = pad + dx + csx / 2 + col * idx;
|
||||
const cy = pad + dy + csy / 2 + row * idy;
|
||||
|
||||
let el;
|
||||
if (isCircle) {
|
||||
const r = Math.min(csx, csy) / 2;
|
||||
el = _circle(svg, cx, cy, r, '#fff', color);
|
||||
} else {
|
||||
el = _rect(svg, cx - csx / 2, cy - csy / 2, csx, csy, '#fff', color);
|
||||
}
|
||||
|
||||
const label = (rowLabels[row] || '') + String(col + 1);
|
||||
_title(el, `${label}: ${csx}x${csy} mm`);
|
||||
|
||||
// hover 效果
|
||||
el.style.cursor = 'pointer';
|
||||
el.addEventListener('mouseenter', () => el.setAttribute('fill', color + '44'));
|
||||
el.addEventListener('mouseleave', () => el.setAttribute('fill', '#fff'));
|
||||
}
|
||||
}
|
||||
|
||||
// dx / dy 标注(板边到首个子元素左上角)
|
||||
const dimColor = '#e67e22';
|
||||
const firstLeft = pad + dx; // 首列子元素左边 X
|
||||
const firstTop = pad + dy; // 首行子元素上边 Y
|
||||
if (dx > 0.1) {
|
||||
// dx: 板左边 → 首列子元素左边,画在第一行子元素中心高度
|
||||
const annY = firstTop + csy / 2;
|
||||
_line(svg, pad, annY, firstLeft, annY, dimColor, '1,1');
|
||||
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
|
||||
_line(svg, firstLeft, annY - 2, firstLeft, annY + 2, dimColor);
|
||||
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
|
||||
}
|
||||
if (dy > 0.1) {
|
||||
// dy: 板上边 → 首行子元素上边,画在第一列子元素中心宽度
|
||||
const annX = firstLeft + csx / 2;
|
||||
_line(svg, annX, pad, annX, firstTop, dimColor, '1,1');
|
||||
_line(svg, annX - 2, pad, annX + 2, pad, dimColor);
|
||||
_line(svg, annX - 2, firstTop, annX + 2, firstTop, dimColor);
|
||||
_text(svg, annX + 4, pad + dy / 2 + 1, `dy=${dy}`, '2.5', 'start', dimColor);
|
||||
}
|
||||
} else if (data.type === 'plate_adapter' && data.adapter) {
|
||||
// 绘制适配器凹槽
|
||||
const adp = data.adapter;
|
||||
const ahx = adp.adapter_hole_size_x || 127;
|
||||
const ahy = adp.adapter_hole_size_y || 85;
|
||||
const adx = adp.dx != null ? adp.dx : (sx - ahx) / 2;
|
||||
const ady = adp.dy != null ? adp.dy : (sy - ahy) / 2;
|
||||
_rect(svg, pad + adx, pad + ady, ahx, ahy, '#f0f0ff', '#8b5cf6', 2);
|
||||
_text(svg, pad + adx + ahx / 2, pad + ady + ahy / 2, `${ahx}x${ahy}`, '4', 'middle', '#8b5cf6');
|
||||
} else if (data.type === 'trash') {
|
||||
// 简单标记
|
||||
_text(svg, pad + sx / 2, pad + sy / 2, 'TRASH', '8', 'middle', '#ef4444');
|
||||
}
|
||||
|
||||
container.appendChild(svg);
|
||||
_enableZoomPan(svg, `0 0 ${w} ${h}`);
|
||||
}
|
||||
|
||||
// ==================== 侧面截面图 ====================
|
||||
function renderSideProfile(container, data) {
|
||||
container.innerHTML = '';
|
||||
if (!data) return;
|
||||
|
||||
const pad = 20;
|
||||
const sx = data.size_x || 127;
|
||||
const sz = data.size_z || 20;
|
||||
|
||||
// 按比例缩放,侧面以 X-Z 面
|
||||
const scaleH = Math.max(1, sz / 60); // 让较矮的板子不会太小
|
||||
|
||||
// 计算枪头露出高度(仅 tip_rack)
|
||||
const tip = data.tip;
|
||||
const grid = data.grid;
|
||||
let tipAbove = 0;
|
||||
if (data.type === 'tip_rack' && tip) {
|
||||
if (tip.tip_above_rack_length != null && tip.tip_above_rack_length > 0) {
|
||||
tipAbove = tip.tip_above_rack_length;
|
||||
} else if (tip.tip_length && grid) {
|
||||
const dz = grid.dz || 0;
|
||||
const calc = tip.tip_length - (sz - dz);
|
||||
if (calc > 0) tipAbove = calc;
|
||||
}
|
||||
}
|
||||
|
||||
const drawW = sx;
|
||||
const drawH = sz;
|
||||
const w = drawW + pad * 2 + 30; // 额外空间给标注
|
||||
const h = drawH + tipAbove + pad * 2 + 10;
|
||||
const svg = _makeSVG(w, h);
|
||||
|
||||
const color = TYPE_COLORS[data.type] || '#3b82f6';
|
||||
const baseY = pad + tipAbove + drawH; // 底部 Y
|
||||
const rackTopY = pad + tipAbove; // rack 顶部 Y
|
||||
|
||||
// 板壳矩形
|
||||
_rect(svg, pad, rackTopY, drawW, drawH, color + '15', color);
|
||||
|
||||
// 尺寸标注
|
||||
// X 方向
|
||||
_line(svg, pad, baseY + 5, pad + drawW, baseY + 5, '#333');
|
||||
_text(svg, pad + drawW / 2, baseY + 12, `${sx} mm`, '3.5', 'middle', '#333');
|
||||
|
||||
// Z 方向
|
||||
_line(svg, pad + drawW + 5, rackTopY, pad + drawW + 5, baseY, '#333');
|
||||
const zt = document.createElementNS(_svgNS(), 'text');
|
||||
zt.setAttribute('x', pad + drawW + 12);
|
||||
zt.setAttribute('y', rackTopY + drawH / 2);
|
||||
zt.setAttribute('font-size', '3.5');
|
||||
zt.setAttribute('text-anchor', 'middle');
|
||||
zt.setAttribute('fill', '#333');
|
||||
zt.setAttribute('font-family', 'sans-serif');
|
||||
zt.setAttribute('transform', `rotate(-90, ${pad + drawW + 12}, ${rackTopY + drawH / 2})`);
|
||||
zt.textContent = `${sz} mm`;
|
||||
svg.appendChild(zt);
|
||||
|
||||
const well = data.well;
|
||||
const tube = data.tube;
|
||||
|
||||
if (grid && (well || tip || tube)) {
|
||||
const dx = grid.dx || 0;
|
||||
const dz = grid.dz || 0;
|
||||
const idx = grid.item_dx || 9;
|
||||
const nx = Math.min(grid.num_items_x || 1, 24); // 最多画24列
|
||||
const dimColor = '#e67e22';
|
||||
|
||||
const child = well || tube;
|
||||
const childTip = tip;
|
||||
|
||||
if (child) {
|
||||
const csx = child.size_x || 8;
|
||||
const csz = child.size_z || 10;
|
||||
const bt = well ? (well.bottom_type || 'FLAT') : 'FLAT';
|
||||
|
||||
// 画几个代表性的孔截面
|
||||
const nDraw = Math.min(nx, 12);
|
||||
for (let i = 0; i < nDraw; i++) {
|
||||
const cx = pad + dx + csx / 2 + i * idx;
|
||||
const topZ = baseY - dz - csz;
|
||||
const botZ = baseY - dz;
|
||||
|
||||
// 孔壁
|
||||
_rect(svg, cx - csx / 2, topZ, csx, csz, '#e0e8ff', color, 0.5);
|
||||
|
||||
// 底部形状
|
||||
if (bt === 'V') {
|
||||
// V 底 三角
|
||||
const triH = Math.min(csx / 2, csz * 0.3);
|
||||
const p = document.createElementNS(_svgNS(), 'polygon');
|
||||
p.setAttribute('points',
|
||||
`${cx - csx / 2},${botZ - triH} ${cx},${botZ} ${cx + csx / 2},${botZ - triH}`);
|
||||
p.setAttribute('fill', color + '33');
|
||||
p.setAttribute('stroke', color);
|
||||
p.setAttribute('stroke-width', '0.3');
|
||||
svg.appendChild(p);
|
||||
} else if (bt === 'U') {
|
||||
// U 底 圆弧
|
||||
const arcR = csx / 2;
|
||||
const p = document.createElementNS(_svgNS(), 'path');
|
||||
p.setAttribute('d', `M ${cx - csx / 2} ${botZ - arcR} A ${arcR} ${arcR} 0 0 0 ${cx + csx / 2} ${botZ - arcR}`);
|
||||
p.setAttribute('fill', color + '33');
|
||||
p.setAttribute('stroke', color);
|
||||
p.setAttribute('stroke-width', '0.3');
|
||||
svg.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
// dz 标注
|
||||
if (dz > 0) {
|
||||
const lx = pad + dx + 0.5 * idx * nDraw + csx / 2 + 5;
|
||||
_line(svg, lx, baseY, lx, baseY - dz, dimColor, '1,1');
|
||||
_line(svg, lx - 2, baseY, lx + 2, baseY, dimColor);
|
||||
_line(svg, lx - 2, baseY - dz, lx + 2, baseY - dz, dimColor);
|
||||
_text(svg, lx + 4, baseY - dz / 2 + 1, `dz=${dz}`, '2.5', 'start', dimColor);
|
||||
}
|
||||
// dx 标注
|
||||
if (dx > 0.1) {
|
||||
const annY = rackTopY + 4;
|
||||
_line(svg, pad, annY, pad + dx, annY, dimColor, '1,1');
|
||||
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
|
||||
_line(svg, pad + dx, annY - 2, pad + dx, annY + 2, dimColor);
|
||||
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
|
||||
}
|
||||
}
|
||||
|
||||
if (childTip) {
|
||||
// 枪头截面
|
||||
const tipLen = childTip.tip_length || 50;
|
||||
const nDraw = Math.min(nx, 12);
|
||||
for (let i = 0; i < nDraw; i++) {
|
||||
const cx = pad + dx + 3.5 + i * idx;
|
||||
// 枪头顶部 = rack顶部 - 露出长度
|
||||
const tipTopZ = rackTopY - tipAbove;
|
||||
const drawLen = Math.min(tipLen, sz - dz + tipAbove);
|
||||
|
||||
// 枪头轮廓 (梯形)
|
||||
const topW = 4;
|
||||
const botW = 1.5;
|
||||
const p = document.createElementNS(_svgNS(), 'polygon');
|
||||
p.setAttribute('points',
|
||||
`${cx - topW / 2},${tipTopZ} ${cx + topW / 2},${tipTopZ} ${cx + botW / 2},${tipTopZ + drawLen} ${cx - botW / 2},${tipTopZ + drawLen}`);
|
||||
p.setAttribute('fill', '#10b98133');
|
||||
p.setAttribute('stroke', '#10b981');
|
||||
p.setAttribute('stroke-width', '0.3');
|
||||
svg.appendChild(p);
|
||||
}
|
||||
|
||||
// dz 标注
|
||||
if (dz > 0) {
|
||||
const lx = pad + dx + nDraw * idx + 5;
|
||||
_line(svg, lx, baseY, lx, baseY - dz, dimColor, '1,1');
|
||||
_line(svg, lx - 2, baseY, lx + 2, baseY, dimColor);
|
||||
_line(svg, lx - 2, baseY - dz, lx + 2, baseY - dz, dimColor);
|
||||
_text(svg, lx + 4, baseY - dz / 2 + 1, `dz=${dz}`, '2.5', 'start', dimColor);
|
||||
}
|
||||
// dx 标注
|
||||
if (dx > 0.1) {
|
||||
const annY = rackTopY + 4;
|
||||
_line(svg, pad, annY, pad + dx, annY, dimColor, '1,1');
|
||||
_line(svg, pad, annY - 2, pad, annY + 2, dimColor);
|
||||
_line(svg, pad + dx, annY - 2, pad + dx, annY + 2, dimColor);
|
||||
_text(svg, pad + dx / 2, annY - 2, `dx=${dx}`, '2.5', 'middle', dimColor);
|
||||
}
|
||||
|
||||
// 露出长度标注线
|
||||
if (tipAbove > 0) {
|
||||
const annotX = pad + dx + nDraw * idx + 8;
|
||||
// rack 顶部水平参考线
|
||||
_line(svg, annotX - 3, rackTopY, annotX + 3, rackTopY, '#10b981');
|
||||
// 枪头顶部水平参考线
|
||||
_line(svg, annotX - 3, rackTopY - tipAbove, annotX + 3, rackTopY - tipAbove, '#10b981');
|
||||
// 竖直标注线
|
||||
_line(svg, annotX, rackTopY - tipAbove, annotX, rackTopY, '#10b981', '1,1');
|
||||
_text(svg, annotX + 2, rackTopY - tipAbove / 2 + 1, `露出=${Math.round(tipAbove * 100) / 100}mm`, '2.5', 'start', '#10b981');
|
||||
}
|
||||
}
|
||||
} else if (data.type === 'plate_adapter' && data.adapter) {
|
||||
const adp = data.adapter;
|
||||
const ahz = adp.adapter_hole_size_z || 10;
|
||||
const adz = adp.dz || 0;
|
||||
const adx_val = adp.dx != null ? adp.dx : (sx - (adp.adapter_hole_size_x || 127)) / 2;
|
||||
const ahx = adp.adapter_hole_size_x || 127;
|
||||
|
||||
// 凹槽截面
|
||||
_rect(svg, pad + adx_val, rackTopY + adz, ahx, ahz, '#ede9fe', '#8b5cf6');
|
||||
_text(svg, pad + adx_val + ahx / 2, rackTopY + adz + ahz / 2 + 1, `hole: ${ahz}mm deep`, '3', 'middle', '#8b5cf6');
|
||||
} else if (data.type === 'trash') {
|
||||
_text(svg, pad + drawW / 2, rackTopY + drawH / 2, 'TRASH', '8', 'middle', '#ef4444');
|
||||
}
|
||||
|
||||
container.appendChild(svg);
|
||||
_enableZoomPan(svg, `0 0 ${w} ${h}`);
|
||||
}
|
||||
|
||||
// ==================== 缩放 & 平移 ====================
|
||||
function _enableZoomPan(svgEl, origViewBox) {
|
||||
const parts = origViewBox.split(' ').map(Number);
|
||||
let vx = parts[0], vy = parts[1], vw = parts[2], vh = parts[3];
|
||||
const origVx = vx, origVy = vy, origW = vw, origH = vh;
|
||||
const MIN_SCALE = 0.5, MAX_SCALE = 5;
|
||||
|
||||
function applyViewBox() {
|
||||
svgEl.setAttribute('viewBox', `${vx} ${vy} ${vw} ${vh}`);
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
vx = origVx; vy = origVy; vw = origW; vh = origH;
|
||||
applyViewBox();
|
||||
}
|
||||
|
||||
// 将 resetView 挂到 svg 元素上,方便外部调用
|
||||
svgEl._resetView = resetView;
|
||||
|
||||
svgEl.addEventListener('wheel', function (e) {
|
||||
e.preventDefault();
|
||||
if (e.ctrlKey) {
|
||||
// pinch / ctrl+scroll → 缩放
|
||||
const factor = e.deltaY > 0 ? 1.08 : 1 / 1.08;
|
||||
const newW = vw * factor;
|
||||
const newH = vh * factor;
|
||||
// 限制缩放范围
|
||||
if (newW < origW / MAX_SCALE || newW > origW * (1 / MIN_SCALE)) return;
|
||||
// 以鼠标位置为缩放中心
|
||||
const rect = svgEl.getBoundingClientRect();
|
||||
const mx = (e.clientX - rect.left) / rect.width;
|
||||
const my = (e.clientY - rect.top) / rect.height;
|
||||
vx += (vw - newW) * mx;
|
||||
vy += (vh - newH) * my;
|
||||
vw = newW;
|
||||
vh = newH;
|
||||
} else {
|
||||
// 普通滚轮 → 平移
|
||||
const panSpeed = vw * 0.002;
|
||||
vx += e.deltaX * panSpeed;
|
||||
vy += e.deltaY * panSpeed;
|
||||
}
|
||||
applyViewBox();
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
// 回中按钮:重置指定容器内 SVG 的 viewBox
|
||||
function resetSvgView(containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
const svg = container.querySelector('svg');
|
||||
if (svg && svg._resetView) svg._resetView();
|
||||
}
|
||||
295
unilabos/labware_manager/static/style.css
Normal file
295
unilabos/labware_manager/static/style.css
Normal file
@@ -0,0 +1,295 @@
|
||||
/* PRCXI 耗材管理 - 全局样式 */
|
||||
|
||||
:root {
|
||||
--c-primary: #3b82f6;
|
||||
--c-primary-dark: #2563eb;
|
||||
--c-danger: #ef4444;
|
||||
--c-warning: #f59e0b;
|
||||
--c-success: #10b981;
|
||||
--c-gray-50: #f9fafb;
|
||||
--c-gray-100: #f3f4f6;
|
||||
--c-gray-200: #e5e7eb;
|
||||
--c-gray-300: #d1d5db;
|
||||
--c-gray-500: #6b7280;
|
||||
--c-gray-700: #374151;
|
||||
--c-gray-900: #111827;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--c-gray-50);
|
||||
color: var(--c-gray-900);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
.topbar {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--c-gray-200);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
height: 56px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.topbar .logo {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--c-gray-900);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 容器 */
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 页头 */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.page-header h1 { font-size: 1.5rem; }
|
||||
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-sm { padding: 4px 10px; font-size: 0.8rem; }
|
||||
.btn-primary { background: var(--c-primary); color: #fff; }
|
||||
.btn-primary:hover { background: var(--c-primary-dark); }
|
||||
.btn-outline { background: #fff; color: var(--c-gray-700); border-color: var(--c-gray-300); }
|
||||
.btn-outline:hover { background: var(--c-gray-100); }
|
||||
.btn-danger { background: var(--c-danger); color: #fff; }
|
||||
.btn-danger:hover { background: #dc2626; }
|
||||
.btn-warning { background: var(--c-warning); color: #fff; }
|
||||
.btn-warning:hover { background: #d97706; }
|
||||
|
||||
/* 徽章 */
|
||||
.badge {
|
||||
background: var(--c-gray-200);
|
||||
color: var(--c-gray-700);
|
||||
font-size: 0.8rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* 状态消息 */
|
||||
.status-msg {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.msg-ok { background: #d1fae5; color: #065f46; }
|
||||
.msg-err { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
/* 类型分段 */
|
||||
.type-section { margin-bottom: 32px; }
|
||||
.type-section h2 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.type-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 卡片网格 */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 耗材卡片 */
|
||||
.labware-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--c-gray-200);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
}
|
||||
.labware-card:hover {
|
||||
border-color: var(--c-primary);
|
||||
box-shadow: 0 4px 12px rgba(59,130,246,0.15);
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--c-gray-900);
|
||||
word-break: break-all;
|
||||
}
|
||||
.card-body { font-size: 0.85rem; color: var(--c-gray-500); }
|
||||
.card-info { margin-bottom: 2px; }
|
||||
.card-info .label { color: var(--c-gray-700); font-weight: 500; }
|
||||
.card-footer {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--c-gray-100);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
/* 标签 */
|
||||
.tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tag-tpl { background: #dbeafe; color: #1e40af; }
|
||||
.tag-plate { background: #dbeafe; color: #1e40af; }
|
||||
.tag-tip_rack { background: #d1fae5; color: #065f46; }
|
||||
.tag-trash { background: #fee2e2; color: #991b1b; }
|
||||
.tag-tube_rack { background: #fef3c7; color: #92400e; }
|
||||
.tag-plate_adapter { background: #ede9fe; color: #5b21b6; }
|
||||
|
||||
/* 详情页布局 */
|
||||
.detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.detail-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
.detail-info, .detail-viz { display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.info-card, .viz-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--c-gray-200);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
.info-card h3, .viz-card h3 {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 12px;
|
||||
color: var(--c-gray-700);
|
||||
border-bottom: 1px solid var(--c-gray-100);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-table { width: 100%; font-size: 0.85rem; }
|
||||
.info-table td { padding: 4px 8px; border-bottom: 1px solid var(--c-gray-100); }
|
||||
.info-table .label { color: var(--c-gray-500); font-weight: 500; width: 140px; }
|
||||
.info-table code { background: var(--c-gray-100); padding: 1px 4px; border-radius: 3px; font-size: 0.8rem; }
|
||||
.info-table code.small { font-size: 0.7rem; }
|
||||
|
||||
/* SVG 容器 */
|
||||
#svg-topdown, #svg-side {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
overflow: hidden;
|
||||
}
|
||||
#svg-topdown svg, #svg-side svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* 编辑页布局 */
|
||||
.edit-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.edit-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
.edit-form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.edit-preview { display: flex; flex-direction: column; gap: 16px; position: sticky; top: 72px; align-self: start; }
|
||||
|
||||
/* 表单 */
|
||||
.form-section {
|
||||
background: #fff;
|
||||
border: 1px solid var(--c-gray-200);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
.form-section h3 {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 12px;
|
||||
color: var(--c-gray-700);
|
||||
}
|
||||
.form-row { margin-bottom: 10px; }
|
||||
.form-row label { display: block; font-size: 0.8rem; color: var(--c-gray-500); margin-bottom: 4px; font-weight: 500; }
|
||||
.form-row input, .form-row select, .form-row textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--c-gray-300);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.form-row input:focus, .form-row select:focus, .form-row textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--c-primary);
|
||||
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
|
||||
}
|
||||
.form-row-2, .form-row-3 { display: grid; gap: 12px; margin-bottom: 10px; }
|
||||
.form-row-2 { grid-template-columns: 1fr 1fr; }
|
||||
.form-row-3 { grid-template-columns: 1fr 1fr 1fr; }
|
||||
.form-row-2 label, .form-row-3 label { display: block; font-size: 0.8rem; color: var(--c-gray-500); margin-bottom: 4px; font-weight: 500; }
|
||||
.form-row-2 input, .form-row-2 select,
|
||||
.form-row-3 input, .form-row-3 select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--c-gray-300);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.form-row-2 input:focus, .form-row-3 input:focus {
|
||||
outline: none;
|
||||
border-color: var(--c-primary);
|
||||
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 双语标签中文部分 */
|
||||
.label-cn { color: var(--c-gray-400, #9ca3af); font-weight: 400; margin-left: 4px; }
|
||||
24
unilabos/labware_manager/templates/base.html
Normal file
24
unilabos/labware_manager/templates/base.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}PRCXI 耗材管理{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
{% block head_extra %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="topbar">
|
||||
<a href="/" class="logo">PRCXI 耗材管理</a>
|
||||
<div class="nav-actions">
|
||||
<a href="/labware/new" class="btn btn-primary btn-sm">+ 新建耗材</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
159
unilabos/labware_manager/templates/detail.html
Normal file
159
unilabos/labware_manager/templates/detail.html
Normal file
@@ -0,0 +1,159 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ item.function_name }} - PRCXI{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>{{ item.function_name }}</h1>
|
||||
<div class="header-actions">
|
||||
<a href="/labware/{{ item.function_name }}/edit" class="btn btn-primary">编辑</a>
|
||||
<a href="/" class="btn btn-outline">返回列表</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-layout">
|
||||
<!-- 左侧: 信息 -->
|
||||
<div class="detail-info">
|
||||
<div class="info-card">
|
||||
<h3>基本信息</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">类型</td><td><span class="tag tag-{{ item.type }}">{{ item.type }}</span></td></tr>
|
||||
<tr><td class="label">函数名</td><td><code>{{ item.function_name }}</code></td></tr>
|
||||
<tr><td class="label">Model</td><td>{{ item.model or '-' }}</td></tr>
|
||||
{% if item.plate_type %}
|
||||
<tr><td class="label">Plate Type</td><td>{{ item.plate_type }}</td></tr>
|
||||
{% endif %}
|
||||
<tr><td class="label">Docstring</td><td>{{ item.docstring or '-' }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>物理尺寸 (mm)</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">X</td><td>{{ item.size_x }}</td></tr>
|
||||
<tr><td class="label">Y</td><td>{{ item.size_y }}</td></tr>
|
||||
<tr><td class="label">Z</td><td>{{ item.size_z }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>材料信息</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">UUID</td><td><code class="small">{{ item.material_info.uuid }}</code></td></tr>
|
||||
<tr><td class="label">Code</td><td>{{ item.material_info.Code }}</td></tr>
|
||||
<tr><td class="label">Name</td><td>{{ item.material_info.Name }}</td></tr>
|
||||
{% if item.material_info.materialEnum is not none %}
|
||||
<tr><td class="label">materialEnum</td><td>{{ item.material_info.materialEnum }}</td></tr>
|
||||
{% endif %}
|
||||
{% if item.material_info.SupplyType is not none %}
|
||||
<tr><td class="label">SupplyType</td><td>{{ item.material_info.SupplyType }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if item.grid %}
|
||||
<div class="info-card">
|
||||
<h3>网格排列</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">列 x 行</td><td>{{ item.grid.num_items_x }} x {{ item.grid.num_items_y }}</td></tr>
|
||||
<tr><td class="label">dx, dy, dz</td><td>{{ item.grid.dx }}, {{ item.grid.dy }}, {{ item.grid.dz }}</td></tr>
|
||||
<tr><td class="label">item_dx, item_dy</td><td>{{ item.grid.item_dx }}, {{ item.grid.item_dy }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.well %}
|
||||
<div class="info-card">
|
||||
<h3>孔参数 (Well)</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">尺寸</td><td>{{ item.well.size_x }} x {{ item.well.size_y }} x {{ item.well.size_z }}</td></tr>
|
||||
{% if item.well.max_volume is not none %}
|
||||
<tr><td class="label">最大体积</td><td>{{ item.well.max_volume }} uL</td></tr>
|
||||
{% endif %}
|
||||
<tr><td class="label">底部类型</td><td>{{ item.well.bottom_type }}</td></tr>
|
||||
<tr><td class="label">截面类型</td><td>{{ item.well.cross_section_type }}</td></tr>
|
||||
{% if item.well.material_z_thickness is not none %}
|
||||
<tr><td class="label">材料Z厚度</td><td>{{ item.well.material_z_thickness }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.tip %}
|
||||
<div class="info-card">
|
||||
<h3>枪头参数 (Tip)</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">Spot 尺寸</td><td>{{ item.tip.spot_size_x }} x {{ item.tip.spot_size_y }} x {{ item.tip.spot_size_z }}</td></tr>
|
||||
<tr><td class="label">容量</td><td>{{ item.tip.tip_volume }} uL</td></tr>
|
||||
<tr><td class="label">长度</td><td>{{ item.tip.tip_length }} mm</td></tr>
|
||||
<tr><td class="label">取枪头插入深度</td><td>{{ item.tip.tip_fitting_depth }} mm</td></tr>
|
||||
{% if item.tip.tip_above_rack_length is not none %}
|
||||
<tr><td class="label">枪头露出枪头盒长度</td><td>{{ item.tip.tip_above_rack_length }} mm</td></tr>
|
||||
{% endif %}
|
||||
<tr><td class="label">有滤芯</td><td>{{ item.tip.has_filter }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.tube %}
|
||||
<div class="info-card">
|
||||
<h3>管参数 (Tube)</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">尺寸</td><td>{{ item.tube.size_x }} x {{ item.tube.size_y }} x {{ item.tube.size_z }}</td></tr>
|
||||
<tr><td class="label">最大体积</td><td>{{ item.tube.max_volume }} uL</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.adapter %}
|
||||
<div class="info-card">
|
||||
<h3>适配器参数</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">Hole 尺寸</td><td>{{ item.adapter.adapter_hole_size_x }} x {{ item.adapter.adapter_hole_size_y }} x {{ item.adapter.adapter_hole_size_z }}</td></tr>
|
||||
<tr><td class="label">dx, dy, dz</td><td>{{ item.adapter.dx }}, {{ item.adapter.dy }}, {{ item.adapter.dz }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="info-card">
|
||||
<h3>Registry</h3>
|
||||
<table class="info-table">
|
||||
<tr><td class="label">分类</td><td>{{ item.registry_category | join(' / ') }}</td></tr>
|
||||
<tr><td class="label">描述</td><td>{{ item.registry_description }}</td></tr>
|
||||
<tr><td class="label">模板匹配</td><td>{{ item.include_in_template_matching }}</td></tr>
|
||||
{% if item.template_kind %}
|
||||
<tr><td class="label">模板类型</td><td>{{ item.template_kind }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧: 可视化 -->
|
||||
<div class="detail-viz">
|
||||
<div class="viz-card">
|
||||
<h3 style="display:flex;align-items:center;justify-content:space-between;">
|
||||
俯视图 (Top-Down)
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-topdown')">回中</button>
|
||||
</h3>
|
||||
<div id="svg-topdown"></div>
|
||||
</div>
|
||||
<div class="viz-card">
|
||||
<h3 style="display:flex;align-items:center;justify-content:space-between;">
|
||||
侧面截面图 (Side Profile)
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-side')">回中</button>
|
||||
</h3>
|
||||
<div id="svg-side"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/labware_viz.js"></script>
|
||||
<script>
|
||||
const itemData = {{ item.model_dump() | tojson }};
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
renderTopDown(document.getElementById('svg-topdown'), itemData);
|
||||
renderSideProfile(document.getElementById('svg-side'), itemData);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
257
unilabos/labware_manager/templates/edit.html
Normal file
257
unilabos/labware_manager/templates/edit.html
Normal file
@@ -0,0 +1,257 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{% if is_new %}新建耗材{% else %}编辑 {{ item.function_name }}{% endif %} - PRCXI{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>{% if is_new %}新建耗材{% else %}编辑 {{ item.function_name }}{% endif %}</h1>
|
||||
<div class="header-actions">
|
||||
<a href="/" class="btn btn-outline">返回列表</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status-msg" class="status-msg" style="display:none;"></div>
|
||||
|
||||
<div class="edit-layout">
|
||||
<!-- 左侧: 表单 -->
|
||||
<div class="edit-form">
|
||||
<form id="labware-form" onsubmit="return false;">
|
||||
<!-- 基本信息 -->
|
||||
<div class="form-section">
|
||||
<h3>基本信息</h3>
|
||||
<div class="form-row">
|
||||
<label>类型</label>
|
||||
<select name="type" id="f-type" onchange="onTypeChange()">
|
||||
<option value="plate" {% if labware_type == 'plate' %}selected{% endif %}>Plate (孔板)</option>
|
||||
<option value="tip_rack" {% if labware_type == 'tip_rack' %}selected{% endif %}>TipRack (吸头盒)</option>
|
||||
<option value="trash" {% if labware_type == 'trash' %}selected{% endif %}>Trash (废弃槽)</option>
|
||||
<option value="tube_rack" {% if labware_type == 'tube_rack' %}selected{% endif %}>TubeRack (管架)</option>
|
||||
<option value="plate_adapter" {% if labware_type == 'plate_adapter' %}selected{% endif %}>PlateAdapter (适配器)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>函数名</label>
|
||||
<input type="text" name="function_name" id="f-function_name"
|
||||
value="{{ item.function_name if item else 'PRCXI_new_labware' }}"
|
||||
placeholder="PRCXI_xxx">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Model</label>
|
||||
<input type="text" name="model" id="f-model"
|
||||
value="{{ item.model if item and item.model else '' }}">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Docstring</label>
|
||||
<textarea name="docstring" id="f-docstring" rows="2">{{ item.docstring if item else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-row" id="row-plate_type" style="display:none;">
|
||||
<label>Plate Type</label>
|
||||
<select name="plate_type" id="f-plate_type">
|
||||
<option value="">-</option>
|
||||
<option value="skirted" {% if item and item.plate_type == 'skirted' %}selected{% endif %}>skirted</option>
|
||||
<option value="semi-skirted" {% if item and item.plate_type == 'semi-skirted' %}selected{% endif %}>semi-skirted</option>
|
||||
<option value="non-skirted" {% if item and item.plate_type == 'non-skirted' %}selected{% endif %}>non-skirted</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 物理尺寸 -->
|
||||
<div class="form-section">
|
||||
<h3>物理尺寸 Physical Dimensions (mm)</h3>
|
||||
<div class="form-row-3">
|
||||
<div><label>size_x <span class="label-cn">板长</span></label><input type="number" step="any" name="size_x" id="f-size_x" value="{{ item.size_x if item else 127 }}"></div>
|
||||
<div><label>size_y <span class="label-cn">板宽</span></label><input type="number" step="any" name="size_y" id="f-size_y" value="{{ item.size_y if item else 85 }}"></div>
|
||||
<div><label>size_z <span class="label-cn">板高</span></label><input type="number" step="any" name="size_z" id="f-size_z" value="{{ item.size_z if item else 20 }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 材料信息 -->
|
||||
<div class="form-section">
|
||||
<h3>材料信息</h3>
|
||||
<div class="form-row">
|
||||
<label>UUID</label>
|
||||
<input type="text" name="mi_uuid" id="f-mi_uuid"
|
||||
value="{{ item.material_info.uuid if item else '' }}">
|
||||
</div>
|
||||
<div class="form-row-2">
|
||||
<div><label>Code</label><input type="text" name="mi_code" id="f-mi_code" value="{{ item.material_info.Code if item else '' }}"></div>
|
||||
<div><label>Name</label><input type="text" name="mi_name" id="f-mi_name" value="{{ item.material_info.Name if item else '' }}"></div>
|
||||
</div>
|
||||
<div class="form-row-2">
|
||||
<div><label>materialEnum</label><input type="number" name="mi_menum" id="f-mi_menum" value="{{ item.material_info.materialEnum if item and item.material_info.materialEnum is not none else '' }}"></div>
|
||||
<div><label>SupplyType</label><input type="number" name="mi_stype" id="f-mi_stype" value="{{ item.material_info.SupplyType if item and item.material_info.SupplyType is not none else '' }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 网格排列 (plate/tip_rack/tube_rack) -->
|
||||
<div class="form-section" id="section-grid" style="display:none;">
|
||||
<h3 style="display:flex;align-items:center;justify-content:space-between;">
|
||||
网格排列 Grid Layout
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="autoCenter()">自动居中 Auto-Center</button>
|
||||
</h3>
|
||||
<div class="form-row-2">
|
||||
<div><label>num_items_x <span class="label-cn">列数</span></label><input type="number" name="grid_nx" id="f-grid_nx" value="{{ item.grid.num_items_x if item and item.grid else 12 }}"></div>
|
||||
<div><label>num_items_y <span class="label-cn">行数</span></label><input type="number" name="grid_ny" id="f-grid_ny" value="{{ item.grid.num_items_y if item and item.grid else 8 }}"></div>
|
||||
</div>
|
||||
<div class="form-row-3">
|
||||
<div><label>dx <span class="label-cn">首孔X偏移</span></label><input type="number" step="any" name="grid_dx" id="f-grid_dx" value="{{ item.grid.dx if item and item.grid else 0 }}"></div>
|
||||
<div><label>dy <span class="label-cn">首孔Y偏移</span></label><input type="number" step="any" name="grid_dy" id="f-grid_dy" value="{{ item.grid.dy if item and item.grid else 0 }}"></div>
|
||||
<div><label>dz <span class="label-cn">孔底Z偏移</span></label><input type="number" step="any" name="grid_dz" id="f-grid_dz" value="{{ item.grid.dz if item and item.grid else 0 }}"></div>
|
||||
</div>
|
||||
<div class="form-row-2">
|
||||
<div><label>item_dx <span class="label-cn">列间距</span></label><input type="number" step="any" name="grid_idx" id="f-grid_idx" value="{{ item.grid.item_dx if item and item.grid else 9 }}"></div>
|
||||
<div><label>item_dy <span class="label-cn">行间距</span></label><input type="number" step="any" name="grid_idy" id="f-grid_idy" value="{{ item.grid.item_dy if item and item.grid else 9 }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Well 参数 (plate) -->
|
||||
<div class="form-section" id="section-well" style="display:none;">
|
||||
<h3>孔参数 Well</h3>
|
||||
<div class="form-row-3">
|
||||
<div><label>size_x <span class="label-cn">孔长</span></label><input type="number" step="any" name="well_sx" id="f-well_sx" value="{{ item.well.size_x if item and item.well else 8 }}"></div>
|
||||
<div><label>size_y <span class="label-cn">孔宽</span></label><input type="number" step="any" name="well_sy" id="f-well_sy" value="{{ item.well.size_y if item and item.well else 8 }}"></div>
|
||||
<div><label>size_z <span class="label-cn">孔深</span></label><input type="number" step="any" name="well_sz" id="f-well_sz" value="{{ item.well.size_z if item and item.well else 10 }}"></div>
|
||||
</div>
|
||||
<div class="form-row-2">
|
||||
<div><label>max_volume <span class="label-cn">最大容量 (uL)</span></label><input type="number" step="any" name="well_vol" id="f-well_vol" value="{{ item.well.max_volume if item and item.well and item.well.max_volume is not none else '' }}"></div>
|
||||
<div><label>material_z_thickness <span class="label-cn">底壁厚度</span></label><input type="number" step="any" name="well_mzt" id="f-well_mzt" value="{{ item.well.material_z_thickness if item and item.well and item.well.material_z_thickness is not none else '' }}"></div>
|
||||
</div>
|
||||
<div class="form-row-2">
|
||||
<div>
|
||||
<label>bottom_type <span class="label-cn">底部形状</span></label>
|
||||
<select name="well_bt" id="f-well_bt">
|
||||
<option value="FLAT" {% if item and item.well and item.well.bottom_type == 'FLAT' %}selected{% endif %}>FLAT</option>
|
||||
<option value="V" {% if item and item.well and item.well.bottom_type == 'V' %}selected{% endif %}>V</option>
|
||||
<option value="U" {% if item and item.well and item.well.bottom_type == 'U' %}selected{% endif %}>U</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>cross_section_type <span class="label-cn">截面形状</span></label>
|
||||
<select name="well_cs" id="f-well_cs">
|
||||
<option value="CIRCLE" {% if item and item.well and item.well.cross_section_type == 'CIRCLE' %}selected{% endif %}>CIRCLE</option>
|
||||
<option value="RECTANGLE" {% if item and item.well and item.well.cross_section_type == 'RECTANGLE' %}selected{% endif %}>RECTANGLE</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label><input type="checkbox" name="has_vf" id="f-has_vf" {% if item and item.volume_functions %}checked{% endif %}> 使用 volume_functions (rectangle)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tip 参数 (tip_rack) -->
|
||||
<div class="form-section" id="section-tip" style="display:none;">
|
||||
<h3>枪头参数 Tip</h3>
|
||||
<div class="form-row-3">
|
||||
<div><label>spot_size_x <span class="label-cn">卡槽长</span></label><input type="number" step="any" name="tip_sx" id="f-tip_sx" value="{{ item.tip.spot_size_x if item and item.tip else 7 }}"></div>
|
||||
<div><label>spot_size_y <span class="label-cn">卡槽宽</span></label><input type="number" step="any" name="tip_sy" id="f-tip_sy" value="{{ item.tip.spot_size_y if item and item.tip else 7 }}"></div>
|
||||
<div><label>spot_size_z <span class="label-cn">卡槽高</span></label><input type="number" step="any" name="tip_sz" id="f-tip_sz" value="{{ item.tip.spot_size_z if item and item.tip else 0 }}"></div>
|
||||
</div>
|
||||
<div class="form-row-3">
|
||||
<div><label>tip_volume <span class="label-cn">枪头容量 (uL)</span></label><input type="number" step="any" name="tip_vol" id="f-tip_vol" value="{{ item.tip.tip_volume if item and item.tip else 300 }}"></div>
|
||||
<div><label>tip_length <span class="label-cn">枪头总长度 (mm)</span></label><input type="number" step="any" name="tip_len" id="f-tip_len" value="{{ item.tip.tip_length if item and item.tip else 60 }}"></div>
|
||||
<div><label>fitting_depth <span class="label-cn">取枪头时插入的长度 (mm)</span></label><input type="number" step="any" name="tip_dep" id="f-tip_dep" value="{{ item.tip.tip_fitting_depth if item and item.tip else 51 }}"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>tip_above_rack_length <span class="label-cn">枪头在枪头盒上方的部分的长度 (mm)</span></label>
|
||||
<input type="number" step="any" name="tip_above" id="f-tip_above"
|
||||
value="{{ item.tip.tip_above_rack_length if item and item.tip and item.tip.tip_above_rack_length is not none else '' }}"
|
||||
placeholder="tip_length - (size_z - dz)">
|
||||
<small style="color:#888;margin-top:2px;">公式: tip_length = tip_above + size_z - dz;填 tip_above 自动算 dz,填 dz 自动算 tip_above</small>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label><input type="checkbox" name="tip_filter" id="f-tip_filter" {% if item and item.tip and item.tip.has_filter %}checked{% endif %}> has_filter</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tube 参数 (tube_rack) -->
|
||||
<div class="form-section" id="section-tube" style="display:none;">
|
||||
<h3>管参数 Tube</h3>
|
||||
<div class="form-row-3">
|
||||
<div><label>size_x <span class="label-cn">管径X</span></label><input type="number" step="any" name="tube_sx" id="f-tube_sx" value="{{ item.tube.size_x if item and item.tube else 10.6 }}"></div>
|
||||
<div><label>size_y <span class="label-cn">管径Y</span></label><input type="number" step="any" name="tube_sy" id="f-tube_sy" value="{{ item.tube.size_y if item and item.tube else 10.6 }}"></div>
|
||||
<div><label>size_z <span class="label-cn">管高</span></label><input type="number" step="any" name="tube_sz" id="f-tube_sz" value="{{ item.tube.size_z if item and item.tube else 40 }}"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>max_volume <span class="label-cn">最大容量 (uL)</span></label>
|
||||
<input type="number" step="any" name="tube_vol" id="f-tube_vol" value="{{ item.tube.max_volume if item and item.tube else 1500 }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Adapter 参数 (plate_adapter) -->
|
||||
<div class="form-section" id="section-adapter" style="display:none;">
|
||||
<h3>适配器参数 Adapter</h3>
|
||||
<div class="form-row-3">
|
||||
<div><label>hole_size_x <span class="label-cn">凹槽长</span></label><input type="number" step="any" name="adp_hsx" id="f-adp_hsx" value="{{ item.adapter.adapter_hole_size_x if item and item.adapter else 127.76 }}"></div>
|
||||
<div><label>hole_size_y <span class="label-cn">凹槽宽</span></label><input type="number" step="any" name="adp_hsy" id="f-adp_hsy" value="{{ item.adapter.adapter_hole_size_y if item and item.adapter else 85.48 }}"></div>
|
||||
<div><label>hole_size_z <span class="label-cn">凹槽深</span></label><input type="number" step="any" name="adp_hsz" id="f-adp_hsz" value="{{ item.adapter.adapter_hole_size_z if item and item.adapter else 10 }}"></div>
|
||||
</div>
|
||||
<div class="form-row-3">
|
||||
<div><label>dx <span class="label-cn">X偏移</span></label><input type="number" step="any" name="adp_dx" id="f-adp_dx" value="{{ item.adapter.dx if item and item.adapter and item.adapter.dx is not none else '' }}"></div>
|
||||
<div><label>dy <span class="label-cn">Y偏移</span></label><input type="number" step="any" name="adp_dy" id="f-adp_dy" value="{{ item.adapter.dy if item and item.adapter and item.adapter.dy is not none else '' }}"></div>
|
||||
<div><label>dz <span class="label-cn">Z偏移</span></label><input type="number" step="any" name="adp_dz" id="f-adp_dz" value="{{ item.adapter.dz if item and item.adapter else 0 }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registry -->
|
||||
<div class="form-section">
|
||||
<h3>Registry / Template</h3>
|
||||
<div class="form-row">
|
||||
<label>registry_category (逗号分隔)</label>
|
||||
<input type="text" name="reg_cat" id="f-reg_cat"
|
||||
value="{{ item.registry_category | join(',') if item else 'prcxi,plates' }}">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>registry_description</label>
|
||||
<input type="text" name="reg_desc" id="f-reg_desc"
|
||||
value="{{ item.registry_description if item else '' }}">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label><input type="checkbox" name="in_tpl" id="f-in_tpl" {% if item and item.include_in_template_matching %}checked{% endif %}> include_in_template_matching</label>
|
||||
</div>
|
||||
<div class="form-row" id="row-tpl_kind">
|
||||
<label>template_kind</label>
|
||||
<input type="text" name="tpl_kind" id="f-tpl_kind"
|
||||
value="{{ item.template_kind if item and item.template_kind else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-primary" onclick="saveForm()">
|
||||
{% if is_new %}创建{% else %}保存{% endif %}
|
||||
</button>
|
||||
<a href="/" class="btn btn-outline">取消</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 右侧: 实时预览 -->
|
||||
<div class="edit-preview">
|
||||
<div class="viz-card">
|
||||
<h3 style="display:flex;align-items:center;justify-content:space-between;">
|
||||
预览: 俯视图
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-topdown')">回中</button>
|
||||
</h3>
|
||||
<div id="svg-topdown"></div>
|
||||
</div>
|
||||
<div class="viz-card">
|
||||
<h3 style="display:flex;align-items:center;justify-content:space-between;">
|
||||
预览: 侧面截面图
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="resetSvgView('svg-side')">回中</button>
|
||||
</h3>
|
||||
<div id="svg-side"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/labware_viz.js"></script>
|
||||
<script src="/static/form_handler.js"></script>
|
||||
<script>
|
||||
const IS_NEW = {{ 'true' if is_new else 'false' }};
|
||||
const ITEM_ID = "{{ item.function_name if item else '' }}";
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
onTypeChange();
|
||||
updatePreview();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
131
unilabos/labware_manager/templates/index.html
Normal file
131
unilabos/labware_manager/templates/index.html
Normal file
@@ -0,0 +1,131 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}耗材列表 - PRCXI{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>耗材列表 <span class="badge">{{ total }}</span></h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-outline" onclick="importFromCode()">从代码导入</button>
|
||||
<button class="btn btn-outline" onclick="generateCode(true)">生成代码 (测试)</button>
|
||||
<button class="btn btn-warning" onclick="generateCode(false)">生成代码 (正式)</button>
|
||||
<a href="/labware/new" class="btn btn-primary">+ 新建耗材</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status-msg" class="status-msg" style="display:none;"></div>
|
||||
|
||||
{% set type_labels = {
|
||||
"plate": "孔板 (Plate)",
|
||||
"tip_rack": "吸头盒 (TipRack)",
|
||||
"trash": "废弃槽 (Trash)",
|
||||
"tube_rack": "管架 (TubeRack)",
|
||||
"plate_adapter": "适配器 (PlateAdapter)"
|
||||
} %}
|
||||
{% set type_colors = {
|
||||
"plate": "#3b82f6",
|
||||
"tip_rack": "#10b981",
|
||||
"trash": "#ef4444",
|
||||
"tube_rack": "#f59e0b",
|
||||
"plate_adapter": "#8b5cf6"
|
||||
} %}
|
||||
|
||||
{% for type_key in ["plate", "tip_rack", "trash", "tube_rack", "plate_adapter"] %}
|
||||
{% if type_key in groups %}
|
||||
<section class="type-section">
|
||||
<h2>
|
||||
<span class="type-dot" style="background:{{ type_colors[type_key] }}"></span>
|
||||
{{ type_labels[type_key] }}
|
||||
<span class="badge">{{ groups[type_key]|length }}</span>
|
||||
</h2>
|
||||
<div class="card-grid">
|
||||
{% for item in groups[type_key] %}
|
||||
<div class="labware-card" onclick="location.href='/labware/{{ item.function_name }}'">
|
||||
<div class="card-header">
|
||||
<span class="card-title">{{ item.function_name }}</span>
|
||||
{% if item.include_in_template_matching %}
|
||||
<span class="tag tag-tpl">TPL</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-info">
|
||||
<span class="label">Code:</span> {{ item.material_info.Code or '-' }}
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<span class="label">名称:</span> {{ item.material_info.Name or '-' }}
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<span class="label">尺寸:</span>
|
||||
{{ item.size_x }} x {{ item.size_y }} x {{ item.size_z }} mm
|
||||
</div>
|
||||
{% if item.grid %}
|
||||
<div class="card-info">
|
||||
<span class="label">网格:</span>
|
||||
{{ item.grid.num_items_x }} x {{ item.grid.num_items_y }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<a href="/labware/{{ item.function_name }}/edit" class="btn btn-sm btn-outline"
|
||||
onclick="event.stopPropagation()">编辑</a>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
onclick="event.stopPropagation(); deleteItem('{{ item.function_name }}')">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function showMsg(text, ok) {
|
||||
const el = document.getElementById('status-msg');
|
||||
el.textContent = text;
|
||||
el.className = 'status-msg ' + (ok ? 'msg-ok' : 'msg-err');
|
||||
el.style.display = 'block';
|
||||
setTimeout(() => el.style.display = 'none', 4000);
|
||||
}
|
||||
|
||||
async function importFromCode() {
|
||||
if (!confirm('将从现有 prcxi_labware.py + YAML 重新导入,覆盖当前 JSON 数据?')) return;
|
||||
const r = await fetch('/api/import-from-code', {method:'POST'});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
showMsg('导入成功: ' + d.count + ' 个耗材', true);
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
showMsg('导入失败: ' + JSON.stringify(d), false);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateCode(testMode) {
|
||||
const label = testMode ? '测试' : '正式';
|
||||
if (!testMode && !confirm('正式模式将覆盖原有 prcxi_labware.py 和 YAML 文件,确定?')) return;
|
||||
const r = await fetch('/api/generate-code', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({test_mode: testMode})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
showMsg(`[${label}] 生成成功: ${d.python_file}`, true);
|
||||
} else {
|
||||
showMsg('生成失败: ' + JSON.stringify(d), false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteItem(id) {
|
||||
if (!confirm('确定删除 ' + id + '?')) return;
|
||||
const r = await fetch('/api/labware/' + id, {method:'DELETE'});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
showMsg('已删除', true);
|
||||
setTimeout(() => location.reload(), 500);
|
||||
} else {
|
||||
showMsg('删除失败', false);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
119
unilabos/labware_manager/yaml_gen.py
Normal file
119
unilabos/labware_manager/yaml_gen.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""JSON → Registry YAML 文件生成。
|
||||
|
||||
按 type 分组输出到对应 YAML 文件(与现有格式完全一致)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
import yaml
|
||||
|
||||
from unilabos.labware_manager.models import LabwareDB, LabwareItem
|
||||
|
||||
_REGISTRY_DIR = Path(__file__).resolve().parents[1] / "registry" / "resources" / "prcxi"
|
||||
|
||||
# type → yaml 文件名
|
||||
_TYPE_TO_YAML = {
|
||||
"plate": "plates",
|
||||
"tip_rack": "tip_racks",
|
||||
"trash": "trash",
|
||||
"tube_rack": "tube_racks",
|
||||
"plate_adapter": "plate_adapters",
|
||||
}
|
||||
|
||||
|
||||
def _build_entry(item: LabwareItem) -> dict:
|
||||
"""构建单个 YAML 条目(与现有格式完全一致)。"""
|
||||
mi = item.material_info
|
||||
desc = item.registry_description
|
||||
if not desc:
|
||||
desc = f'{mi.Name} (Code: {mi.Code})' if mi.Name and mi.Code else item.function_name
|
||||
|
||||
return {
|
||||
"category": list(item.registry_category),
|
||||
"class": {
|
||||
"module": f"unilabos.devices.liquid_handling.prcxi.prcxi_labware:{item.function_name}",
|
||||
"type": "pylabrobot",
|
||||
},
|
||||
"description": desc,
|
||||
"handles": [],
|
||||
"icon": "",
|
||||
"init_param_schema": {},
|
||||
"version": "1.0.0",
|
||||
}
|
||||
|
||||
|
||||
class _YAMLDumper(yaml.SafeDumper):
|
||||
"""自定义 Dumper: 空列表输出为 [],空字典输出为 {}。"""
|
||||
pass
|
||||
|
||||
|
||||
def _represent_list(dumper, data):
|
||||
if not data:
|
||||
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=True)
|
||||
return dumper.represent_sequence("tag:yaml.org,2002:seq", data)
|
||||
|
||||
|
||||
def _represent_dict(dumper, data):
|
||||
if not data:
|
||||
return dumper.represent_mapping("tag:yaml.org,2002:map", data, flow_style=True)
|
||||
return dumper.represent_mapping("tag:yaml.org,2002:map", data)
|
||||
|
||||
|
||||
def _represent_str(dumper, data):
|
||||
if '\n' in data or ':' in data or "'" in data:
|
||||
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="'")
|
||||
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
|
||||
|
||||
|
||||
_YAMLDumper.add_representer(list, _represent_list)
|
||||
_YAMLDumper.add_representer(dict, _represent_dict)
|
||||
_YAMLDumper.add_representer(str, _represent_str)
|
||||
|
||||
|
||||
def generate_yaml(db: LabwareDB, test_mode: bool = True) -> List[Path]:
|
||||
"""生成所有 registry YAML 文件,返回输出文件路径列表。"""
|
||||
suffix = "_test" if test_mode else ""
|
||||
|
||||
# 按 type 分组
|
||||
groups: Dict[str, Dict[str, dict]] = defaultdict(dict)
|
||||
for item in db.items:
|
||||
yaml_key = _TYPE_TO_YAML.get(item.type)
|
||||
if yaml_key is None:
|
||||
continue
|
||||
groups[yaml_key][item.function_name] = _build_entry(item)
|
||||
|
||||
out_paths: List[Path] = []
|
||||
for yaml_key, entries in groups.items():
|
||||
out_path = _REGISTRY_DIR / f"{yaml_key}{suffix}.yaml"
|
||||
|
||||
# 备份
|
||||
if out_path.exists():
|
||||
bak = out_path.with_suffix(".yaml.bak")
|
||||
shutil.copy2(out_path, bak)
|
||||
|
||||
# 按函数名排序
|
||||
sorted_entries = dict(sorted(entries.items()))
|
||||
|
||||
content = yaml.dump(sorted_entries, Dumper=_YAMLDumper, allow_unicode=True,
|
||||
default_flow_style=False, sort_keys=False)
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
out_paths.append(out_path)
|
||||
|
||||
return out_paths
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from unilabos.labware_manager.importer import load_db
|
||||
db = load_db()
|
||||
if not db.items:
|
||||
print("labware_db.json 为空,请先运行 importer.py")
|
||||
else:
|
||||
paths = generate_yaml(db, test_mode=True)
|
||||
print(f"已生成 {len(paths)} 个 YAML 文件:")
|
||||
for p in paths:
|
||||
print(f" {p}")
|
||||
@@ -1,634 +0,0 @@
|
||||
# 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
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
"""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"]
|
||||
@@ -1,66 +0,0 @@
|
||||
"""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]
|
||||
@@ -1,626 +0,0 @@
|
||||
"""约束体系:硬约束 / 软约束定义与统一评估。
|
||||
|
||||
硬约束违反 → 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
|
||||
@@ -1,302 +0,0 @@
|
||||
"""双源设备目录:从 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
|
||||
@@ -1,559 +0,0 @@
|
||||
"""从 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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,187 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,373 +0,0 @@
|
||||
"""意图解释器:将语义化意图翻译为 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
|
||||
@@ -1,41 +0,0 @@
|
||||
"""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 如果可达
|
||||
"""
|
||||
...
|
||||
@@ -1,49 +0,0 @@
|
||||
"""解析实验室平面图 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)
|
||||
@@ -1,187 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,312 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,110 +0,0 @@
|
||||
"""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
|
||||
@@ -1,96 +0,0 @@
|
||||
"""数据模型定义: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 = "" # 可选的自然语言描述(用于审计/调试)
|
||||
@@ -1,257 +0,0 @@
|
||||
"""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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,144 +0,0 @@
|
||||
"""初始布局生成: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
|
||||
@@ -1,29 +0,0 @@
|
||||
[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"]
|
||||
@@ -1,433 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,331 +0,0 @@
|
||||
"""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)
|
||||
]
|
||||
@@ -1,742 +0,0 @@
|
||||
"""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": []}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
[
|
||||
{"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"}
|
||||
]
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"width": 5.0,
|
||||
"depth": 4.0,
|
||||
"obstacles": [
|
||||
{"x": 2.5, "y": 0.0, "width": 0.1, "depth": 0.5}
|
||||
]
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
"""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
|
||||
@@ -1,435 +0,0 @@
|
||||
"""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
|
||||
@@ -1,505 +0,0 @@
|
||||
"""约束体系测试。"""
|
||||
|
||||
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
|
||||
@@ -1,234 +0,0 @@
|
||||
"""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"
|
||||
@@ -1,263 +0,0 @@
|
||||
"""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
|
||||
@@ -1,210 +0,0 @@
|
||||
"""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
|
||||
@@ -1,134 +0,0 @@
|
||||
"""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
|
||||
@@ -1,277 +0,0 @@
|
||||
"""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
|
||||
@@ -1,138 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,156 +0,0 @@
|
||||
"""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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,430 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,113 +0,0 @@
|
||||
"""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
|
||||
File diff suppressed because it is too large
Load Diff
589
unilabos/registry/devices/bioyond.yaml
Normal file
589
unilabos/registry/devices/bioyond.yaml
Normal file
@@ -0,0 +1,589 @@
|
||||
workstation.bioyond_dispensing_station:
|
||||
category:
|
||||
- workstation
|
||||
- bioyond
|
||||
class:
|
||||
action_value_mappings:
|
||||
auto-batch_create_90_10_vial_feeding_tasks:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
delay_time: null
|
||||
hold_m_name: null
|
||||
liquid_material_name: NMP
|
||||
speed: null
|
||||
temperature: null
|
||||
titration: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
delay_time:
|
||||
type: string
|
||||
hold_m_name:
|
||||
type: string
|
||||
liquid_material_name:
|
||||
default: NMP
|
||||
type: string
|
||||
speed:
|
||||
type: string
|
||||
temperature:
|
||||
type: string
|
||||
titration:
|
||||
type: string
|
||||
required:
|
||||
- titration
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: batch_create_90_10_vial_feeding_tasks参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-batch_create_diamine_solution_tasks:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
delay_time: null
|
||||
liquid_material_name: NMP
|
||||
solutions: null
|
||||
speed: null
|
||||
temperature: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
delay_time:
|
||||
type: string
|
||||
liquid_material_name:
|
||||
default: NMP
|
||||
type: string
|
||||
solutions:
|
||||
type: string
|
||||
speed:
|
||||
type: string
|
||||
temperature:
|
||||
type: string
|
||||
required:
|
||||
- solutions
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: batch_create_diamine_solution_tasks参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-brief_step_parameters:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
data: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
required:
|
||||
- data
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: brief_step_parameters参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-compute_experiment_design:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
m_tot: '70'
|
||||
ratio: null
|
||||
titration_percent: '0.03'
|
||||
wt_percent: '0.25'
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
m_tot:
|
||||
default: '70'
|
||||
type: string
|
||||
ratio:
|
||||
type: object
|
||||
titration_percent:
|
||||
default: '0.03'
|
||||
type: string
|
||||
wt_percent:
|
||||
default: '0.25'
|
||||
type: string
|
||||
required:
|
||||
- ratio
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
feeding_order:
|
||||
items: {}
|
||||
title: Feeding Order
|
||||
type: array
|
||||
return_info:
|
||||
title: Return Info
|
||||
type: string
|
||||
solutions:
|
||||
items: {}
|
||||
title: Solutions
|
||||
type: array
|
||||
solvents:
|
||||
additionalProperties: true
|
||||
title: Solvents
|
||||
type: object
|
||||
titration:
|
||||
additionalProperties: true
|
||||
title: Titration
|
||||
type: object
|
||||
required:
|
||||
- solutions
|
||||
- titration
|
||||
- solvents
|
||||
- feeding_order
|
||||
- return_info
|
||||
title: ComputeExperimentDesignReturn
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: compute_experiment_design参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-process_order_finish_report:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
report_request: null
|
||||
used_materials: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
report_request:
|
||||
type: string
|
||||
used_materials:
|
||||
type: string
|
||||
required:
|
||||
- report_request
|
||||
- used_materials
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: process_order_finish_report参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-project_order_report:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
order_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
order_id:
|
||||
type: string
|
||||
required:
|
||||
- order_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: project_order_report参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-query_resource_by_name:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
material_name: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
material_name:
|
||||
type: string
|
||||
required:
|
||||
- material_name
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: query_resource_by_name参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-transfer_materials_to_reaction_station:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
target_device_id: null
|
||||
transfer_groups: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
target_device_id:
|
||||
type: string
|
||||
transfer_groups:
|
||||
type: array
|
||||
required:
|
||||
- target_device_id
|
||||
- transfer_groups
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: transfer_materials_to_reaction_station参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-wait_for_multiple_orders_and_get_reports:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
batch_create_result: null
|
||||
check_interval: 10
|
||||
timeout: 7200
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
batch_create_result:
|
||||
type: string
|
||||
check_interval:
|
||||
default: 10
|
||||
type: integer
|
||||
timeout:
|
||||
default: 7200
|
||||
type: integer
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: wait_for_multiple_orders_and_get_reports参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-workflow_sample_locations:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
workflow_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
workflow_id:
|
||||
type: string
|
||||
required:
|
||||
- workflow_id
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: workflow_sample_locations参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
create_90_10_vial_feeding_task:
|
||||
feedback: {}
|
||||
goal:
|
||||
delay_time: delay_time
|
||||
hold_m_name: hold_m_name
|
||||
order_name: order_name
|
||||
percent_10_1_assign_material_name: percent_10_1_assign_material_name
|
||||
percent_10_1_liquid_material_name: percent_10_1_liquid_material_name
|
||||
percent_10_1_target_weigh: percent_10_1_target_weigh
|
||||
percent_10_1_volume: percent_10_1_volume
|
||||
percent_10_2_assign_material_name: percent_10_2_assign_material_name
|
||||
percent_10_2_liquid_material_name: percent_10_2_liquid_material_name
|
||||
percent_10_2_target_weigh: percent_10_2_target_weigh
|
||||
percent_10_2_volume: percent_10_2_volume
|
||||
percent_10_3_assign_material_name: percent_10_3_assign_material_name
|
||||
percent_10_3_liquid_material_name: percent_10_3_liquid_material_name
|
||||
percent_10_3_target_weigh: percent_10_3_target_weigh
|
||||
percent_10_3_volume: percent_10_3_volume
|
||||
percent_90_1_assign_material_name: percent_90_1_assign_material_name
|
||||
percent_90_1_target_weigh: percent_90_1_target_weigh
|
||||
percent_90_2_assign_material_name: percent_90_2_assign_material_name
|
||||
percent_90_2_target_weigh: percent_90_2_target_weigh
|
||||
percent_90_3_assign_material_name: percent_90_3_assign_material_name
|
||||
percent_90_3_target_weigh: percent_90_3_target_weigh
|
||||
speed: speed
|
||||
temperature: temperature
|
||||
goal_default:
|
||||
delay_time: ''
|
||||
hold_m_name: ''
|
||||
order_name: ''
|
||||
percent_10_1_assign_material_name: ''
|
||||
percent_10_1_liquid_material_name: ''
|
||||
percent_10_1_target_weigh: ''
|
||||
percent_10_1_volume: ''
|
||||
percent_10_2_assign_material_name: ''
|
||||
percent_10_2_liquid_material_name: ''
|
||||
percent_10_2_target_weigh: ''
|
||||
percent_10_2_volume: ''
|
||||
percent_10_3_assign_material_name: ''
|
||||
percent_10_3_liquid_material_name: ''
|
||||
percent_10_3_target_weigh: ''
|
||||
percent_10_3_volume: ''
|
||||
percent_90_1_assign_material_name: ''
|
||||
percent_90_1_target_weigh: ''
|
||||
percent_90_2_assign_material_name: ''
|
||||
percent_90_2_target_weigh: ''
|
||||
percent_90_3_assign_material_name: ''
|
||||
percent_90_3_target_weigh: ''
|
||||
speed: ''
|
||||
temperature: ''
|
||||
handles: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
title: DispenStationVialFeed_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties:
|
||||
delay_time:
|
||||
type: string
|
||||
hold_m_name:
|
||||
type: string
|
||||
order_name:
|
||||
type: string
|
||||
percent_10_1_assign_material_name:
|
||||
type: string
|
||||
percent_10_1_liquid_material_name:
|
||||
type: string
|
||||
percent_10_1_target_weigh:
|
||||
type: string
|
||||
percent_10_1_volume:
|
||||
type: string
|
||||
percent_10_2_assign_material_name:
|
||||
type: string
|
||||
percent_10_2_liquid_material_name:
|
||||
type: string
|
||||
percent_10_2_target_weigh:
|
||||
type: string
|
||||
percent_10_2_volume:
|
||||
type: string
|
||||
percent_10_3_assign_material_name:
|
||||
type: string
|
||||
percent_10_3_liquid_material_name:
|
||||
type: string
|
||||
percent_10_3_target_weigh:
|
||||
type: string
|
||||
percent_10_3_volume:
|
||||
type: string
|
||||
percent_90_1_assign_material_name:
|
||||
type: string
|
||||
percent_90_1_target_weigh:
|
||||
type: string
|
||||
percent_90_2_assign_material_name:
|
||||
type: string
|
||||
percent_90_2_target_weigh:
|
||||
type: string
|
||||
percent_90_3_assign_material_name:
|
||||
type: string
|
||||
percent_90_3_target_weigh:
|
||||
type: string
|
||||
speed:
|
||||
type: string
|
||||
temperature:
|
||||
type: string
|
||||
required:
|
||||
- order_name
|
||||
- percent_90_1_assign_material_name
|
||||
- percent_90_1_target_weigh
|
||||
- percent_90_2_assign_material_name
|
||||
- percent_90_2_target_weigh
|
||||
- percent_90_3_assign_material_name
|
||||
- percent_90_3_target_weigh
|
||||
- percent_10_1_assign_material_name
|
||||
- percent_10_1_target_weigh
|
||||
- percent_10_1_volume
|
||||
- percent_10_1_liquid_material_name
|
||||
- percent_10_2_assign_material_name
|
||||
- percent_10_2_target_weigh
|
||||
- percent_10_2_volume
|
||||
- percent_10_2_liquid_material_name
|
||||
- percent_10_3_assign_material_name
|
||||
- percent_10_3_target_weigh
|
||||
- percent_10_3_volume
|
||||
- percent_10_3_liquid_material_name
|
||||
- speed
|
||||
- temperature
|
||||
- delay_time
|
||||
- hold_m_name
|
||||
title: DispenStationVialFeed_Goal
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: DispenStationVialFeed_Result
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: DispenStationVialFeed
|
||||
type: object
|
||||
type: DispenStationVialFeed
|
||||
create_diamine_solution_task:
|
||||
feedback: {}
|
||||
goal:
|
||||
delay_time: delay_time
|
||||
hold_m_name: hold_m_name
|
||||
liquid_material_name: liquid_material_name
|
||||
material_name: material_name
|
||||
order_name: order_name
|
||||
speed: speed
|
||||
target_weigh: target_weigh
|
||||
temperature: temperature
|
||||
volume: volume
|
||||
goal_default:
|
||||
delay_time: ''
|
||||
hold_m_name: ''
|
||||
liquid_material_name: ''
|
||||
material_name: ''
|
||||
order_name: ''
|
||||
speed: ''
|
||||
target_weigh: ''
|
||||
temperature: ''
|
||||
volume: ''
|
||||
handles: {}
|
||||
result:
|
||||
return_info: return_info
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties: {}
|
||||
required: []
|
||||
title: DispenStationSolnPrep_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties:
|
||||
delay_time:
|
||||
type: string
|
||||
hold_m_name:
|
||||
type: string
|
||||
liquid_material_name:
|
||||
type: string
|
||||
material_name:
|
||||
type: string
|
||||
order_name:
|
||||
type: string
|
||||
speed:
|
||||
type: string
|
||||
target_weigh:
|
||||
type: string
|
||||
temperature:
|
||||
type: string
|
||||
volume:
|
||||
type: string
|
||||
required:
|
||||
- order_name
|
||||
- material_name
|
||||
- target_weigh
|
||||
- volume
|
||||
- liquid_material_name
|
||||
- speed
|
||||
- temperature
|
||||
- delay_time
|
||||
- hold_m_name
|
||||
title: DispenStationSolnPrep_Goal
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
required:
|
||||
- return_info
|
||||
title: DispenStationSolnPrep_Result
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: DispenStationSolnPrep
|
||||
type: object
|
||||
type: DispenStationSolnPrep
|
||||
module: unilabos.devices.workstation.bioyond_studio.dispensing_station:BioyondDispensingStation
|
||||
status_types: {}
|
||||
type: python
|
||||
config_info: []
|
||||
description: ''
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
config:
|
||||
type: string
|
||||
deck:
|
||||
type: string
|
||||
required:
|
||||
- config
|
||||
- deck
|
||||
type: object
|
||||
data:
|
||||
properties: {}
|
||||
required: []
|
||||
type: object
|
||||
version: 1.0.0
|
||||
@@ -5303,13 +5303,13 @@ liquid_handler.biomek:
|
||||
handler_key: tip_rack
|
||||
label: tip_rack
|
||||
output:
|
||||
- data_key: liquid
|
||||
- data_key: sources
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: sources_out
|
||||
label: sources
|
||||
- data_key: liquid
|
||||
data_source: executor
|
||||
- data_key: targets
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: targets_out
|
||||
label: targets
|
||||
@@ -7581,6 +7581,43 @@ liquid_handler.prcxi:
|
||||
title: iter_tips参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-magnetic_action:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
height: null
|
||||
is_wait: null
|
||||
module_no: null
|
||||
time: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
height:
|
||||
type: integer
|
||||
is_wait:
|
||||
type: boolean
|
||||
module_no:
|
||||
type: integer
|
||||
time:
|
||||
type: integer
|
||||
required:
|
||||
- time
|
||||
- module_no
|
||||
- height
|
||||
- is_wait
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: magnetic_action参数
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
auto-move_to:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -7614,10 +7651,61 @@ liquid_handler.prcxi:
|
||||
title: move_to参数
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
auto-plr_pos_to_prcxi:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
resource: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
resource:
|
||||
type: object
|
||||
required:
|
||||
- resource
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: plr_pos_to_prcxi参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-post_init:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
ros_node: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
ros_node:
|
||||
type: object
|
||||
required:
|
||||
- ros_node
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: post_init参数
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-run_protocol:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default: {}
|
||||
goal_default:
|
||||
protocol_id: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
@@ -7626,7 +7714,9 @@ liquid_handler.prcxi:
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties: {}
|
||||
properties:
|
||||
protocol_id:
|
||||
type: string
|
||||
required: []
|
||||
type: object
|
||||
result: {}
|
||||
@@ -7709,6 +7799,47 @@ liquid_handler.prcxi:
|
||||
title: shaker_action参数
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
auto-shaking_incubation_action:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
amplitude: null
|
||||
is_wait: null
|
||||
module_no: null
|
||||
temperature: null
|
||||
time: null
|
||||
handles: {}
|
||||
placeholder_keys: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
amplitude:
|
||||
type: integer
|
||||
is_wait:
|
||||
type: boolean
|
||||
module_no:
|
||||
type: integer
|
||||
temperature:
|
||||
type: integer
|
||||
time:
|
||||
type: integer
|
||||
required:
|
||||
- time
|
||||
- module_no
|
||||
- amplitude
|
||||
- is_wait
|
||||
- temperature
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: shaking_incubation_action参数
|
||||
type: object
|
||||
type: UniLabJsonCommandAsync
|
||||
auto-touch_tip:
|
||||
feedback: {}
|
||||
goal: {}
|
||||
@@ -9780,48 +9911,28 @@ liquid_handler.prcxi:
|
||||
type: Transfer
|
||||
transfer_liquid:
|
||||
feedback: {}
|
||||
goal:
|
||||
asp_flow_rates: asp_flow_rates
|
||||
asp_vols: asp_vols
|
||||
blow_out_air_volume: blow_out_air_volume
|
||||
delays: delays
|
||||
dis_flow_rates: dis_flow_rates
|
||||
dis_vols: dis_vols
|
||||
is_96_well: is_96_well
|
||||
liquid_height: liquid_height
|
||||
mix_liquid_height: mix_liquid_height
|
||||
mix_rate: mix_rate
|
||||
mix_stage: mix_stage
|
||||
mix_times: mix_times
|
||||
mix_vol: mix_vol
|
||||
none_keys: none_keys
|
||||
offsets: offsets
|
||||
sources: sources
|
||||
spread: spread
|
||||
targets: targets
|
||||
tip_racks: tip_racks
|
||||
touch_tip: touch_tip
|
||||
use_channels: use_channels
|
||||
goal: {}
|
||||
goal_default:
|
||||
asp_flow_rates: []
|
||||
asp_vols: []
|
||||
blow_out_air_volume: []
|
||||
delays: []
|
||||
dis_flow_rates: []
|
||||
dis_vols: []
|
||||
asp_flow_rates: null
|
||||
asp_vols: null
|
||||
blow_out_air_volume: null
|
||||
blow_out_air_volume_before: null
|
||||
delays: null
|
||||
dis_flow_rates: null
|
||||
dis_vols: null
|
||||
is_96_well: false
|
||||
liquid_height: []
|
||||
mix_liquid_height: 0.0
|
||||
mix_rate: 0
|
||||
mix_stage: ''
|
||||
mix_times: 0
|
||||
mix_vol: 0
|
||||
liquid_height: null
|
||||
mix_liquid_height: null
|
||||
mix_rate: null
|
||||
mix_stage: none
|
||||
mix_times: null
|
||||
mix_vol: null
|
||||
none_keys: []
|
||||
offsets: []
|
||||
sources: []
|
||||
spread: ''
|
||||
targets: []
|
||||
tip_racks: []
|
||||
offsets: null
|
||||
sources: null
|
||||
spread: wide
|
||||
targets: null
|
||||
tip_racks: null
|
||||
touch_tip: false
|
||||
use_channels: []
|
||||
handles:
|
||||
@@ -9836,7 +9947,7 @@ liquid_handler.prcxi:
|
||||
data_type: resource
|
||||
handler_key: targets_identifier
|
||||
label: 转移目标
|
||||
- data_key: tip_rack
|
||||
- data_key: tip_racks
|
||||
data_source: handle
|
||||
data_type: resource
|
||||
handler_key: tip_rack_identifier
|
||||
@@ -9862,10 +9973,7 @@ liquid_handler.prcxi:
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
additionalProperties: true
|
||||
title: LiquidHandlerTransfer_Feedback
|
||||
type: object
|
||||
feedback: {}
|
||||
goal:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
@@ -9881,6 +9989,10 @@ liquid_handler.prcxi:
|
||||
items:
|
||||
type: number
|
||||
type: array
|
||||
blow_out_air_volume_before:
|
||||
items:
|
||||
type: number
|
||||
type: array
|
||||
delays:
|
||||
items:
|
||||
type: integer
|
||||
@@ -9894,6 +10006,7 @@ liquid_handler.prcxi:
|
||||
type: number
|
||||
type: array
|
||||
is_96_well:
|
||||
default: false
|
||||
type: boolean
|
||||
liquid_height:
|
||||
items:
|
||||
@@ -9908,6 +10021,7 @@ liquid_handler.prcxi:
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
mix_stage:
|
||||
default: none
|
||||
type: string
|
||||
mix_times:
|
||||
maximum: 2147483647
|
||||
@@ -9918,6 +10032,7 @@ liquid_handler.prcxi:
|
||||
minimum: -2147483648
|
||||
type: integer
|
||||
none_keys:
|
||||
default: []
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
@@ -10011,6 +10126,7 @@ liquid_handler.prcxi:
|
||||
type: object
|
||||
type: array
|
||||
spread:
|
||||
default: wide
|
||||
type: string
|
||||
targets:
|
||||
items:
|
||||
@@ -10161,27 +10277,228 @@ liquid_handler.prcxi:
|
||||
type: object
|
||||
type: array
|
||||
touch_tip:
|
||||
default: false
|
||||
type: boolean
|
||||
use_channels:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
title: LiquidHandlerTransfer_Goal
|
||||
required:
|
||||
- sources
|
||||
- targets
|
||||
- tip_racks
|
||||
- asp_vols
|
||||
- dis_vols
|
||||
type: object
|
||||
result:
|
||||
additionalProperties: false
|
||||
$defs:
|
||||
ResourceDict:
|
||||
properties:
|
||||
class:
|
||||
description: Resource class name
|
||||
title: Class
|
||||
type: string
|
||||
config:
|
||||
additionalProperties: true
|
||||
description: Resource configuration
|
||||
title: Config
|
||||
type: object
|
||||
data:
|
||||
additionalProperties: true
|
||||
description: 'Resource data, eg: container liquid data'
|
||||
title: Data
|
||||
type: object
|
||||
description:
|
||||
default: ''
|
||||
description: Resource description
|
||||
title: Description
|
||||
type: string
|
||||
extra:
|
||||
additionalProperties: true
|
||||
description: 'Extra data, eg: slot index'
|
||||
title: Extra
|
||||
type: object
|
||||
icon:
|
||||
default: ''
|
||||
description: Resource icon
|
||||
title: Icon
|
||||
type: string
|
||||
id:
|
||||
description: Resource ID
|
||||
title: Id
|
||||
type: string
|
||||
model:
|
||||
additionalProperties: true
|
||||
description: Resource model
|
||||
title: Model
|
||||
type: object
|
||||
name:
|
||||
description: Resource name
|
||||
title: Name
|
||||
type: string
|
||||
parent:
|
||||
anyOf:
|
||||
- $ref: '#/$defs/ResourceDict'
|
||||
- type: 'null'
|
||||
default: null
|
||||
description: Parent resource object
|
||||
parent_uuid:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
default: null
|
||||
description: Parent resource uuid
|
||||
title: Parent Uuid
|
||||
pose:
|
||||
$ref: '#/$defs/ResourceDictPosition'
|
||||
description: Resource position
|
||||
schema:
|
||||
additionalProperties: true
|
||||
description: Resource schema
|
||||
title: Schema
|
||||
type: object
|
||||
type:
|
||||
anyOf:
|
||||
- const: device
|
||||
type: string
|
||||
- type: string
|
||||
description: Resource type
|
||||
title: Type
|
||||
uuid:
|
||||
description: Resource UUID
|
||||
title: Uuid
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- uuid
|
||||
- name
|
||||
- type
|
||||
- class
|
||||
- config
|
||||
- data
|
||||
- extra
|
||||
title: ResourceDict
|
||||
type: object
|
||||
ResourceDictPosition:
|
||||
properties:
|
||||
cross_section_type:
|
||||
default: rectangle
|
||||
description: Cross section type
|
||||
enum:
|
||||
- rectangle
|
||||
- circle
|
||||
- rounded_rectangle
|
||||
title: Cross Section Type
|
||||
type: string
|
||||
layout:
|
||||
default: x-y
|
||||
description: Resource layout
|
||||
enum:
|
||||
- 2d
|
||||
- x-y
|
||||
- z-y
|
||||
- x-z
|
||||
title: Layout
|
||||
type: string
|
||||
position:
|
||||
$ref: '#/$defs/ResourceDictPositionObject'
|
||||
description: Resource position
|
||||
position3d:
|
||||
$ref: '#/$defs/ResourceDictPositionObject'
|
||||
description: Resource position in 3D space
|
||||
rotation:
|
||||
$ref: '#/$defs/ResourceDictPositionObject'
|
||||
description: Resource rotation
|
||||
scale:
|
||||
$ref: '#/$defs/ResourceDictPositionScale'
|
||||
description: Resource scale
|
||||
size:
|
||||
$ref: '#/$defs/ResourceDictPositionSize'
|
||||
description: Resource size
|
||||
title: ResourceDictPosition
|
||||
type: object
|
||||
ResourceDictPositionObject:
|
||||
properties:
|
||||
x:
|
||||
default: 0.0
|
||||
description: X coordinate
|
||||
title: X
|
||||
type: number
|
||||
y:
|
||||
default: 0.0
|
||||
description: Y coordinate
|
||||
title: Y
|
||||
type: number
|
||||
z:
|
||||
default: 0.0
|
||||
description: Z coordinate
|
||||
title: Z
|
||||
type: number
|
||||
title: ResourceDictPositionObject
|
||||
type: object
|
||||
ResourceDictPositionScale:
|
||||
properties:
|
||||
x:
|
||||
default: 0.0
|
||||
description: x scale
|
||||
title: X
|
||||
type: number
|
||||
y:
|
||||
default: 0.0
|
||||
description: y scale
|
||||
title: Y
|
||||
type: number
|
||||
z:
|
||||
default: 0.0
|
||||
description: z scale
|
||||
title: Z
|
||||
type: number
|
||||
title: ResourceDictPositionScale
|
||||
type: object
|
||||
ResourceDictPositionSize:
|
||||
properties:
|
||||
depth:
|
||||
default: 0.0
|
||||
description: Depth
|
||||
title: Depth
|
||||
type: number
|
||||
height:
|
||||
default: 0.0
|
||||
description: Height
|
||||
title: Height
|
||||
type: number
|
||||
width:
|
||||
default: 0.0
|
||||
description: Width
|
||||
title: Width
|
||||
type: number
|
||||
title: ResourceDictPositionSize
|
||||
type: object
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
title: LiquidHandlerTransfer_Result
|
||||
sources:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/$defs/ResourceDict'
|
||||
type: array
|
||||
title: Sources
|
||||
type: array
|
||||
targets:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/$defs/ResourceDict'
|
||||
type: array
|
||||
title: Targets
|
||||
type: array
|
||||
required:
|
||||
- sources
|
||||
- targets
|
||||
title: TransferLiquidReturn
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: LiquidHandlerTransfer
|
||||
title: transfer_liquid参数
|
||||
type: object
|
||||
type: LiquidHandlerTransfer
|
||||
type: UniLabJsonCommandAsync
|
||||
module: unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Handler
|
||||
status_types:
|
||||
reset_ok: bool
|
||||
@@ -10204,6 +10521,12 @@ liquid_handler.prcxi:
|
||||
type: string
|
||||
deck:
|
||||
type: object
|
||||
deck_y:
|
||||
default: 400
|
||||
type: string
|
||||
deck_z:
|
||||
default: 300
|
||||
type: string
|
||||
host:
|
||||
type: string
|
||||
is_9320:
|
||||
@@ -10214,17 +10537,44 @@ liquid_handler.prcxi:
|
||||
type: string
|
||||
port:
|
||||
type: integer
|
||||
rail_interval:
|
||||
default: 0
|
||||
type: string
|
||||
rail_nums:
|
||||
default: 4
|
||||
type: string
|
||||
rail_width:
|
||||
default: 27.5
|
||||
type: string
|
||||
setup:
|
||||
default: true
|
||||
type: string
|
||||
simulator:
|
||||
default: false
|
||||
type: string
|
||||
start_rail:
|
||||
default: 2
|
||||
type: string
|
||||
step_mode:
|
||||
default: false
|
||||
type: string
|
||||
timeout:
|
||||
type: number
|
||||
x_increase:
|
||||
default: -0.003636
|
||||
type: string
|
||||
x_offset:
|
||||
default: -0.8
|
||||
type: string
|
||||
xy_coupling:
|
||||
default: -0.0045
|
||||
type: string
|
||||
y_increase:
|
||||
default: -0.003636
|
||||
type: string
|
||||
y_offset:
|
||||
default: -37.98
|
||||
type: string
|
||||
required:
|
||||
- deck
|
||||
- host
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user