test: 新增编译器全链路测试和资源转换测试

- test_pump_separate_full_chain: PumpTransfer和Separate全链路测试,
  验证bug修复后separate不再crash
- test_full_chain_conversion_to_compile: HeatChill/Add协议结构验证
- test_resource_conversion_path: ResourceDictInstance转换路径测试
- test_batch_transfer_protocol: AGV批量转运编译器测试
- test_agv_transport_station: AGV工作站设备测试

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Junhan Chang
2026-03-25 13:12:27 +08:00
parent 0ab4027de7
commit 80272d691d
6 changed files with 2001 additions and 0 deletions

View File

@@ -0,0 +1,538 @@
"""
PumpTransfer 和 Separate 全链路测试
构建包含泵/阀门/分液漏斗的完整设备图,
输出完整的中间数据(最短路径、泵骨架、动作列表等)。
"""
import copy
import json
import pprint
import pytest
import networkx as nx
from unilabos.resources.resource_tracker import ResourceTreeSet
from unilabos.compile.utils.resource_helper import get_resource_id, get_resource_data
from unilabos.compile.utils.vessel_parser import get_vessel
def _make_raw_resource(id, uuid=None, name=None, klass="", type_="device",
parent=None, parent_uuid=None, data=None, config=None, extra=None):
return {
"id": id,
"uuid": uuid or f"uuid-{id}",
"name": name or id,
"class": klass,
"type": type_,
"parent": parent,
"parent_uuid": parent_uuid or "",
"description": "",
"config": config or {},
"data": data or {},
"extra": extra or {},
"position": {"x": 0.0, "y": 0.0, "z": 0.0},
}
def _simulate_enrichment(raw_data_list):
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data_list)
root = tree_set.trees[0].root_node if tree_set.trees else None
return root.get_plr_nested_dict() if root else {}
def _build_pump_transfer_graph():
"""
构建带泵/阀门的设备图,用于测试 PumpTransfer:
flask_water (container)
valve_1 (multiway_valve, pump_1 连接)
reactor_01 (device)
同时有: stirrer_1, heatchill_1, separator_1
"""
G = nx.DiGraph()
# 源容器
G.add_node("flask_water", **{
"id": "flask_water", "name": "flask_water",
"type": "container", "class": "",
"data": {"reagent_name": "water", "liquid": [{"liquid_type": "water", "volume": 200.0}]},
"config": {"reagent": "water"},
})
# 多通阀
G.add_node("valve_1", **{
"id": "valve_1", "name": "valve_1",
"type": "device", "class": "multiway_valve",
"data": {}, "config": {},
})
# 注射泵(连接到阀门)
G.add_node("pump_1", **{
"id": "pump_1", "name": "pump_1",
"type": "device", "class": "virtual_pump",
"data": {}, "config": {"max_volume": 25.0},
})
# 目标容器
G.add_node("reactor_01", **{
"id": "reactor_01", "name": "reactor_01",
"type": "device", "class": "virtual_stirrer",
"data": {"liquid": [{"liquid_type": "water", "volume": 50.0}]},
"config": {},
})
# 搅拌器
G.add_node("stirrer_1", **{
"id": "stirrer_1", "name": "stirrer_1",
"type": "device", "class": "virtual_stirrer",
"data": {}, "config": {},
})
# 加热器
G.add_node("heatchill_1", **{
"id": "heatchill_1", "name": "heatchill_1",
"type": "device", "class": "virtual_heatchill",
"data": {}, "config": {},
})
# 分离器
G.add_node("separator_1", **{
"id": "separator_1", "name": "separator_1",
"type": "device", "class": "separator_controller",
"data": {}, "config": {},
})
# 废液容器
G.add_node("waste_workup", **{
"id": "waste_workup", "name": "waste_workup",
"type": "container", "class": "",
"data": {}, "config": {},
})
# 产物收集瓶
G.add_node("product_flask", **{
"id": "product_flask", "name": "product_flask",
"type": "container", "class": "",
"data": {}, "config": {},
})
# DCM溶剂瓶
G.add_node("flask_dcm", **{
"id": "flask_dcm", "name": "flask_dcm",
"type": "container", "class": "",
"data": {"reagent_name": "dcm", "liquid": [{"liquid_type": "dcm", "volume": 500.0}]},
"config": {"reagent": "dcm"},
})
# 边连接 —— flask_water → valve_1 → reactor_01
G.add_edge("flask_water", "valve_1", port={"valve_1": "port_1"})
G.add_edge("valve_1", "reactor_01", port={"valve_1": "port_2"})
# 阀门 → 泵
G.add_edge("valve_1", "pump_1")
G.add_edge("pump_1", "valve_1")
# 搅拌器 ↔ reactor
G.add_edge("stirrer_1", "reactor_01")
# 加热器 ↔ reactor
G.add_edge("heatchill_1", "reactor_01")
# 分离器 ↔ reactor
G.add_edge("separator_1", "reactor_01")
G.add_edge("reactor_01", "separator_1")
# DCM → valve → reactor (同一泵路)
G.add_edge("flask_dcm", "valve_1", port={"valve_1": "port_3"})
# reactor → valve → product/waste
G.add_edge("valve_1", "product_flask", port={"valve_1": "port_4"})
G.add_edge("valve_1", "waste_workup", port={"valve_1": "port_5"})
return G
def _format_action(action, indent=0):
"""格式化单个 action 为可读字符串"""
prefix = " " * indent
if isinstance(action, list):
# 并行动作
lines = [f"{prefix}[PARALLEL]"]
for sub in action:
lines.append(_format_action(sub, indent + 1))
return "\n".join(lines)
name = action.get("action_name", "?")
device = action.get("device_id", "")
kwargs = action.get("action_kwargs", {})
comment = action.get("_comment", "")
meta = action.get("_transfer_meta", "")
parts = [f"{prefix}{device}::{name}"]
if kwargs:
# 精简输出
kw_str = ", ".join(f"{k}={v}" for k, v in kwargs.items()
if k not in ("progress_message",))
if kw_str:
parts.append(f" kwargs: {{{kw_str}}}")
if comment:
parts.append(f" # {comment}")
if meta:
parts.append(f" meta: {meta}")
return "\n".join(f"{prefix}{p}" if i > 0 else p for i, p in enumerate(parts))
def _dump_actions(actions, title=""):
"""打印完整动作列表"""
print(f"\n{'='*70}")
print(f" {title}")
print(f" 总动作数: {len(actions)}")
print(f"{'='*70}")
for i, action in enumerate(actions):
print(f"\n [{i:02d}] {_format_action(action, indent=2)}")
print(f"\n{'='*70}\n")
# ==================== PumpTransfer 全链路 ====================
class TestPumpTransferFullChain:
"""PumpTransfer: 包含图路径查找、泵骨架构建、动作序列生成"""
def test_pump_transfer_basic(self):
"""基础泵转移flask_water → valve_1 → reactor_01"""
from unilabos.compile.pump_protocol import generate_pump_protocol
G = _build_pump_transfer_graph()
# 检查最短路径
path = nx.shortest_path(G, "flask_water", "reactor_01")
print(f"\n最短路径: {path}")
assert "valve_1" in path
# 调用编译器
actions = generate_pump_protocol(
G=G,
from_vessel_id="flask_water",
to_vessel_id="reactor_01",
volume=10.0,
flowrate=2.5,
transfer_flowrate=0.5,
)
_dump_actions(actions, "PumpTransfer: flask_water → reactor_01, 10mL")
# 验证
assert isinstance(actions, list)
assert len(actions) > 0
# 应该有 set_valve_position 和 set_position 动作
flat = [a for a in actions if isinstance(a, dict)]
action_names = [a.get("action_name") for a in flat]
print(f"动作名称列表: {action_names}")
assert "set_valve_position" in action_names
assert "set_position" in action_names
def test_pump_transfer_with_rinsing_enriched_vessel(self):
"""pump_with_rinsing 接收 enriched vessel dict"""
from unilabos.compile.pump_protocol import generate_pump_protocol_with_rinsing
G = _build_pump_transfer_graph()
# 模拟 enrichment
from_raw = [_make_raw_resource(
id="flask_water", klass="", type_="container",
data={"reagent_name": "water", "liquid": [{"liquid_type": "water", "volume": 200.0}]},
)]
to_raw = [_make_raw_resource(
id="reactor_01", klass="virtual_stirrer", type_="device",
)]
from_enriched = _simulate_enrichment(from_raw)
to_enriched = _simulate_enrichment(to_raw)
print(f"\nfrom_vessel enriched: {json.dumps(from_enriched, indent=2, ensure_ascii=False)[:300]}...")
print(f"to_vessel enriched: {json.dumps(to_enriched, indent=2, ensure_ascii=False)[:300]}...")
# get_vessel 兼容
fid, fdata = get_vessel(from_enriched)
tid, tdata = get_vessel(to_enriched)
print(f"from_vessel_id={fid}, to_vessel_id={tid}")
assert fid == "flask_water"
assert tid == "reactor_01"
actions = generate_pump_protocol_with_rinsing(
G=G,
from_vessel=from_enriched,
to_vessel=to_enriched,
volume=15.0,
flowrate=2.5,
transfer_flowrate=0.5,
)
_dump_actions(actions, "PumpTransferWithRinsing: flask_water → reactor_01, 15mL (enriched)")
assert isinstance(actions, list)
assert len(actions) > 0
def test_pump_transfer_multi_batch(self):
"""体积 > max_volume 时自动分批"""
from unilabos.compile.pump_protocol import generate_pump_protocol
G = _build_pump_transfer_graph()
# pump_1 的 max_volume = 25mL转 60mL 应该分 3 批
actions = generate_pump_protocol(
G=G,
from_vessel_id="flask_water",
to_vessel_id="reactor_01",
volume=60.0,
flowrate=2.5,
transfer_flowrate=0.5,
)
_dump_actions(actions, "PumpTransfer 分批: 60mL (max_volume=25mL, 预期 3 批)")
assert len(actions) > 0
# 应该有多轮 set_position
flat = [a for a in actions if isinstance(a, dict)]
set_position_count = sum(1 for a in flat if a.get("action_name") == "set_position")
print(f"set_position 动作数: {set_position_count}")
# 3批 × 2次 (吸液 + 排液) = 6 次 set_position
assert set_position_count >= 6
def test_pump_transfer_no_path(self):
"""无路径时返回空"""
from unilabos.compile.pump_protocol import generate_pump_protocol
G = _build_pump_transfer_graph()
G.add_node("isolated_flask", type="container")
actions = generate_pump_protocol(
G=G,
from_vessel_id="isolated_flask",
to_vessel_id="reactor_01",
volume=10.0,
)
print(f"\n无路径时的动作列表: {actions}")
assert actions == []
def test_pump_backbone_filtering(self):
"""验证泵骨架过滤逻辑(电磁阀被跳过)"""
from unilabos.compile.pump_protocol import generate_pump_protocol
G = _build_pump_transfer_graph()
# 添加电磁阀到路径中
G.add_node("solenoid_valve_1", **{
"type": "device", "class": "solenoid_valve",
"data": {}, "config": {},
})
# flask_water → solenoid_valve_1 → valve_1 → reactor_01
G.remove_edge("flask_water", "valve_1")
G.add_edge("flask_water", "solenoid_valve_1")
G.add_edge("solenoid_valve_1", "valve_1")
path = nx.shortest_path(G, "flask_water", "reactor_01")
print(f"\n含电磁阀的路径: {path}")
assert "solenoid_valve_1" in path
actions = generate_pump_protocol(
G=G,
from_vessel_id="flask_water",
to_vessel_id="reactor_01",
volume=10.0,
)
_dump_actions(actions, "PumpTransfer 含电磁阀: flask_water → solenoid → valve_1 → reactor_01")
# 电磁阀应被跳过,泵骨架只有 valve_1
assert len(actions) > 0
# ==================== Separate 全链路 ====================
class TestSeparateProtocolFullChain:
"""Separate: 包含 bug 确认和正常路径测试"""
def test_separate_bug_line_128_fixed(self):
"""验证 separate_protocol.py:128 的 bug 已修复(不再 crash"""
from unilabos.compile.separate_protocol import generate_separate_protocol
G = _build_pump_transfer_graph()
raw_data = [_make_raw_resource(
id="reactor_01", klass="virtual_stirrer",
data={"liquid": [{"liquid_type": "water", "volume": 100.0}]},
)]
enriched = _simulate_enrichment(raw_data)
# 修复前final_vessel_id, _ = vessel_id 会 crash字符串解包
# 修复后final_vessel_id = vessel_id正常返回 action 列表
result = generate_separate_protocol(
G=G,
vessel=enriched,
purpose="extract",
product_phase="top",
product_vessel="product_flask",
waste_vessel="waste_workup",
solvent="dcm",
volume="100 mL",
)
assert isinstance(result, list)
assert len(result) > 0
def test_separate_manual_workaround(self):
"""
绕过 line 128 bug手动测试分离编译器中可以工作的子函数
"""
from unilabos.compile.separate_protocol import (
find_separator_device,
find_separation_vessel_bottom,
)
from unilabos.compile.utils.vessel_parser import (
find_connected_stirrer,
find_solvent_vessel,
)
from unilabos.compile.utils.unit_parser import parse_volume_input
from unilabos.compile.utils.resource_helper import get_resource_liquid_volume as get_vessel_liquid_volume
G = _build_pump_transfer_graph()
# 1. get_vessel 解析 enriched dict
raw_data = [_make_raw_resource(
id="reactor_01", klass="virtual_stirrer",
data={"liquid": [{"liquid_type": "water", "volume": 100.0}]},
)]
enriched = _simulate_enrichment(raw_data)
vessel_id, vessel_data = get_vessel(enriched)
print(f"\nvessel_id: {vessel_id}")
print(f"vessel_data: {vessel_data}")
assert vessel_id == "reactor_01"
assert vessel_data["liquid"][0]["volume"] == 100.0
# 2. find_separator_device
sep = find_separator_device(G, vessel_id)
print(f"分离器设备: {sep}")
assert sep == "separator_1"
# 3. find_connected_stirrer
stirrer = find_connected_stirrer(G, vessel_id)
print(f"搅拌器设备: {stirrer}")
assert stirrer == "stirrer_1"
# 4. find_solvent_vessel
solvent_v = find_solvent_vessel(G, "dcm")
print(f"DCM溶剂容器: {solvent_v}")
assert solvent_v == "flask_dcm"
# 5. parse_volume_input
vol = parse_volume_input("200 mL")
print(f"体积解析: '200 mL'{vol}")
assert vol == 200.0
vol2 = parse_volume_input("1.5 L")
print(f"体积解析: '1.5 L'{vol2}")
assert vol2 == 1500.0
# 6. get_vessel_liquid_volume
liq_vol = get_vessel_liquid_volume(enriched)
print(f"液体体积 (enriched dict): {liq_vol}")
assert liq_vol == 100.0
# 7. find_separation_vessel_bottom
bottom = find_separation_vessel_bottom(G, vessel_id)
print(f"分离容器底部: {bottom}")
# 当前图中没有命名匹配的底部容器
def test_pump_transfer_for_separate_subflow(self):
"""测试 separate 中调用的 pump 子流程(溶剂添加 → 分液漏斗)"""
from unilabos.compile.pump_protocol import generate_pump_protocol_with_rinsing
G = _build_pump_transfer_graph()
# 模拟分离前的溶剂添加步骤
actions = generate_pump_protocol_with_rinsing(
G=G,
from_vessel="flask_dcm",
to_vessel="reactor_01",
volume=100.0,
flowrate=2.5,
transfer_flowrate=0.5,
)
_dump_actions(actions, "Separate 子流程: flask_dcm → reactor_01, 100mL DCM")
assert isinstance(actions, list)
assert len(actions) > 0
# 模拟分离后产物转移
actions2 = generate_pump_protocol_with_rinsing(
G=G,
from_vessel="reactor_01",
to_vessel="product_flask",
volume=50.0,
flowrate=2.5,
transfer_flowrate=0.5,
)
_dump_actions(actions2, "Separate 子流程: reactor_01 → product_flask, 50mL 产物")
assert len(actions2) > 0
# 废液转移
actions3 = generate_pump_protocol_with_rinsing(
G=G,
from_vessel="reactor_01",
to_vessel="waste_workup",
volume=50.0,
flowrate=2.5,
transfer_flowrate=0.5,
)
_dump_actions(actions3, "Separate 子流程: reactor_01 → waste_workup, 50mL 废液")
assert len(actions3) > 0
# ==================== 图路径可视化 ====================
class TestGraphPathVisualization:
"""输出图中关键路径信息"""
def test_all_shortest_paths(self):
"""输出所有容器之间的最短路径"""
G = _build_pump_transfer_graph()
containers = [n for n in G.nodes() if G.nodes[n].get("type") == "container"]
devices = [n for n in G.nodes() if G.nodes[n].get("type") == "device"]
print(f"\n{'='*70}")
print(f" 设备图概览")
print(f"{'='*70}")
print(f" 容器节点 ({len(containers)}): {containers}")
print(f" 设备节点 ({len(devices)}): {devices}")
print(f" 边数: {G.number_of_edges()}")
print(f" 边列表:")
for u, v, data in G.edges(data=True):
port_info = data.get("port", "")
print(f" {u}{v} {port_info if port_info else ''}")
print(f"\n 关键路径:")
pairs = [
("flask_water", "reactor_01"),
("flask_dcm", "reactor_01"),
("reactor_01", "product_flask"),
("reactor_01", "waste_workup"),
("flask_water", "product_flask"),
]
for src, dst in pairs:
try:
path = nx.shortest_path(G, src, dst)
length = len(path) - 1
# 标注路径上的节点类型
annotated = []
for n in path:
ntype = G.nodes[n].get("type", "?")
nclass = G.nodes[n].get("class", "")
annotated.append(f"{n}({ntype}{'/' + nclass if nclass else ''})")
print(f" {src}{dst}: 距离={length}")
print(f" 路径: {''.join(annotated)}")
except nx.NetworkXNoPath:
print(f" {src}{dst}: 无路径!")
print(f"{'='*70}\n")