mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-03-26 05:19:05 +00:00
feat: 新增AGV批量物料转运功能
添加AGV工作站设备驱动、注册表定义、批量转运编译器和消息定义。 包含跨工作站批量转运协议、AGV路径规划、容量分批等功能。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -553,8 +553,13 @@ def main():
|
||||
os._exit(0)
|
||||
|
||||
if not BasicConfig.ak or not BasicConfig.sk:
|
||||
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
|
||||
os._exit(1)
|
||||
if BasicConfig.test_mode:
|
||||
print_status("测试模式:跳过 ak/sk 检查,使用占位凭据", "warning")
|
||||
BasicConfig.ak = BasicConfig.ak or "test_ak"
|
||||
BasicConfig.sk = BasicConfig.sk or "test_sk"
|
||||
else:
|
||||
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
|
||||
os._exit(1)
|
||||
graph: nx.Graph
|
||||
resource_tree_set: ResourceTreeSet
|
||||
resource_links: List[Dict[str, Any]]
|
||||
|
||||
@@ -5,6 +5,7 @@ from .separate_protocol import generate_separate_protocol
|
||||
from .evaporate_protocol import generate_evaporate_protocol
|
||||
from .evacuateandrefill_protocol import generate_evacuateandrefill_protocol
|
||||
from .agv_transfer_protocol import generate_agv_transfer_protocol
|
||||
from .batch_transfer_protocol import generate_batch_transfer_protocol
|
||||
from .add_protocol import generate_add_protocol
|
||||
from .centrifuge_protocol import generate_centrifuge_protocol
|
||||
from .filter_protocol import generate_filter_protocol
|
||||
@@ -31,6 +32,7 @@ from .hydrogenate_protocol import generate_hydrogenate_protocol
|
||||
action_protocol_generators = {
|
||||
AddProtocol: generate_add_protocol,
|
||||
AGVTransferProtocol: generate_agv_transfer_protocol,
|
||||
BatchTransferProtocol: generate_batch_transfer_protocol,
|
||||
AdjustPHProtocol: generate_adjust_ph_protocol,
|
||||
CentrifugeProtocol: generate_centrifuge_protocol,
|
||||
CleanProtocol: generate_clean_protocol,
|
||||
|
||||
127
unilabos/compile/_agv_utils.py
Normal file
127
unilabos/compile/_agv_utils.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
AGV 编译器共用工具函数
|
||||
|
||||
从 physical_setup_graph 中发现 AGV 节点配置,
|
||||
供 agv_transfer_protocol 和 batch_transfer_protocol 复用。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import networkx as nx
|
||||
|
||||
|
||||
def find_agv_config(G: nx.Graph, agv_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""从设备图中发现 AGV 节点,返回其配置
|
||||
|
||||
查找策略:
|
||||
1. 如果指定 agv_id,直接读取该节点
|
||||
2. 否则查找 class 为 "agv_transport_station" 的节点
|
||||
3. 兜底查找 config 中包含 device_roles 的 workstation 节点
|
||||
|
||||
Returns:
|
||||
{
|
||||
"agv_id": str,
|
||||
"device_roles": {"navigator": "...", "arm": "..."},
|
||||
"route_table": {"A->B": {"nav_command": ..., "arm_pick": ..., "arm_place": ...}},
|
||||
"capacity": int,
|
||||
}
|
||||
"""
|
||||
if agv_id and agv_id in G.nodes:
|
||||
node_data = G.nodes[agv_id]
|
||||
config = _extract_config(node_data)
|
||||
if config and "device_roles" in config:
|
||||
return _build_agv_cfg(agv_id, config, G)
|
||||
|
||||
# 查找 agv_transport_station 类型
|
||||
for nid, ndata in G.nodes(data=True):
|
||||
node_class = _get_node_class(ndata)
|
||||
if node_class == "agv_transport_station":
|
||||
config = _extract_config(ndata)
|
||||
return _build_agv_cfg(nid, config or {}, G)
|
||||
|
||||
# 兜底:查找带有 device_roles 的 workstation
|
||||
for nid, ndata in G.nodes(data=True):
|
||||
node_class = _get_node_class(ndata)
|
||||
if node_class == "workstation":
|
||||
config = _extract_config(ndata)
|
||||
if config and "device_roles" in config:
|
||||
return _build_agv_cfg(nid, config, G)
|
||||
|
||||
raise ValueError("设备图中未找到 AGV 节点(需 class=agv_transport_station 或 config.device_roles)")
|
||||
|
||||
|
||||
def get_agv_capacity(G: nx.Graph, agv_id: str) -> int:
|
||||
"""从 AGV 的 Warehouse 子节点计算载具容量"""
|
||||
for neighbor in G.successors(agv_id) if G.is_directed() else G.neighbors(agv_id):
|
||||
ndata = G.nodes[neighbor]
|
||||
node_type = _get_node_type(ndata)
|
||||
if node_type == "warehouse":
|
||||
config = _extract_config(ndata)
|
||||
if config:
|
||||
x = config.get("num_items_x", 1)
|
||||
y = config.get("num_items_y", 1)
|
||||
z = config.get("num_items_z", 1)
|
||||
return x * y * z
|
||||
# 如果没有 warehouse 子节点,尝试从配置中读取
|
||||
return 0
|
||||
|
||||
|
||||
def split_batches(items: list, capacity: int) -> List[list]:
|
||||
"""按 AGV 容量分批
|
||||
|
||||
Args:
|
||||
items: 待转运的物料列表
|
||||
capacity: AGV 单批次容量
|
||||
|
||||
Returns:
|
||||
分批后的列表的列表
|
||||
"""
|
||||
if capacity <= 0:
|
||||
raise ValueError(f"AGV 容量必须 > 0,当前: {capacity}")
|
||||
return [items[i:i + capacity] for i in range(0, len(items), capacity)]
|
||||
|
||||
|
||||
def _extract_config(node_data: dict) -> Optional[dict]:
|
||||
"""从节点数据中提取 config 字段,兼容多种格式"""
|
||||
# 直接 config 字段
|
||||
config = node_data.get("config")
|
||||
if isinstance(config, dict):
|
||||
return config
|
||||
# res_content 嵌套格式
|
||||
res_content = node_data.get("res_content")
|
||||
if hasattr(res_content, "config"):
|
||||
return res_content.config if isinstance(res_content.config, dict) else None
|
||||
if isinstance(res_content, dict):
|
||||
return res_content.get("config")
|
||||
return None
|
||||
|
||||
|
||||
def _get_node_class(node_data: dict) -> str:
|
||||
"""获取节点的 class 字段"""
|
||||
res_content = node_data.get("res_content")
|
||||
if hasattr(res_content, "model_dump"):
|
||||
d = res_content.model_dump()
|
||||
return d.get("class_", d.get("class", ""))
|
||||
if isinstance(res_content, dict):
|
||||
return res_content.get("class_", res_content.get("class", ""))
|
||||
return node_data.get("class_", node_data.get("class", ""))
|
||||
|
||||
|
||||
def _get_node_type(node_data: dict) -> str:
|
||||
"""获取节点的 type 字段"""
|
||||
res_content = node_data.get("res_content")
|
||||
if hasattr(res_content, "type"):
|
||||
return res_content.type or ""
|
||||
if isinstance(res_content, dict):
|
||||
return res_content.get("type", "")
|
||||
return node_data.get("type", "")
|
||||
|
||||
|
||||
def _build_agv_cfg(agv_id: str, config: dict, G: nx.Graph) -> Dict[str, Any]:
|
||||
"""构建标准化的 AGV 配置"""
|
||||
return {
|
||||
"agv_id": agv_id,
|
||||
"device_roles": config.get("device_roles", {}),
|
||||
"route_table": config.get("route_table", {}),
|
||||
"capacity": get_agv_capacity(G, agv_id),
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
"""
|
||||
AGV 单物料转运编译器
|
||||
|
||||
从 physical_setup_graph 中查询 AGV 配置(device_roles, route_table),
|
||||
不再硬编码 device_id 和路由表。
|
||||
"""
|
||||
|
||||
import networkx as nx
|
||||
from unilabos.compile._agv_utils import find_agv_config
|
||||
|
||||
|
||||
def generate_agv_transfer_protocol(
|
||||
@@ -17,37 +25,32 @@ def generate_agv_transfer_protocol(
|
||||
from_repo_id = from_repo_["id"]
|
||||
to_repo_id = to_repo_["id"]
|
||||
|
||||
wf_list = {
|
||||
("AiChemEcoHiWo", "zhixing_agv"): {"nav_command" : '{"target" : "LM14"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_biaozhi.urp"}'},
|
||||
("AiChemEcoHiWo", "AGV"): {"nav_command" : '{"target" : "LM14"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_biaozhi.urp"}'},
|
||||
# 从 G 中查询 AGV 配置
|
||||
agv_cfg = find_agv_config(G)
|
||||
device_roles = agv_cfg["device_roles"]
|
||||
route_table = agv_cfg["route_table"]
|
||||
|
||||
("zhixing_agv", "Revvity"): {"nav_command" : '{"target" : "LM13"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_put_board.urp"}'},
|
||||
route_key = f"{from_repo_id}->{to_repo_id}"
|
||||
if route_key not in route_table:
|
||||
raise KeyError(f"AGV 路由表中未找到路线: {route_key},可用路线: {list(route_table.keys())}")
|
||||
|
||||
("AGV", "Revvity"): {"nav_command" : '{"target" : "LM13"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_put_board.urp"}'},
|
||||
route = route_table[route_key]
|
||||
nav_device = device_roles.get("navigator", device_roles.get("nav"))
|
||||
arm_device = device_roles.get("arm")
|
||||
|
||||
("Revvity", "HPLC"): {"nav_command": '{"target" : "LM13"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_hplc.urp"}'},
|
||||
|
||||
("HPLC", "Revvity"): {"nav_command": '{"target" : "LM13"}',
|
||||
"arm_command": '{"task_name" : "camera/250111_lfp.urp"}'},
|
||||
}
|
||||
return [
|
||||
{
|
||||
"device_id": "zhixing_agv",
|
||||
"device_id": nav_device,
|
||||
"action_name": "send_nav_task",
|
||||
"action_kwargs": {
|
||||
"command": wf_list[(from_repo_id, to_repo_id)]["nav_command"]
|
||||
"command": route["nav_command"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"device_id": "zhixing_ur_arm",
|
||||
"device_id": arm_device,
|
||||
"action_name": "move_pos_task",
|
||||
"action_kwargs": {
|
||||
"command": wf_list[(from_repo_id, to_repo_id)]["arm_command"]
|
||||
"command": route.get("arm_command", route.get("arm_place", ""))
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
228
unilabos/compile/batch_transfer_protocol.py
Normal file
228
unilabos/compile/batch_transfer_protocol.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
批量物料转运编译器
|
||||
|
||||
将 BatchTransferProtocol 编译为多批次的 nav → pick × N → nav → place × N 动作序列。
|
||||
自动按 AGV 容量分批,全程维护三方 children dict 的物料系统一致性。
|
||||
"""
|
||||
|
||||
import copy
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import networkx as nx
|
||||
|
||||
from unilabos.compile._agv_utils import find_agv_config, split_batches
|
||||
|
||||
|
||||
def generate_batch_transfer_protocol(
|
||||
G: nx.Graph,
|
||||
from_repo: dict,
|
||||
to_repo: dict,
|
||||
transfer_resources: list,
|
||||
from_positions: list,
|
||||
to_positions: list,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""编译批量转运协议为可执行的 action steps
|
||||
|
||||
Args:
|
||||
G: 设备图 (physical_setup_graph)
|
||||
from_repo: 来源工站资源 dict({station_id: {..., children: {...}}})
|
||||
to_repo: 目标工站资源 dict(含堆栈和位置信息)
|
||||
transfer_resources: 被转运的物料列表(Resource dict)
|
||||
from_positions: 来源 slot 位置列表(与 transfer_resources 平行)
|
||||
to_positions: 目标 slot 位置列表(与 transfer_resources 平行)
|
||||
|
||||
Returns:
|
||||
action steps 列表,ROS2WorkstationNode 按序执行
|
||||
"""
|
||||
if not transfer_resources:
|
||||
return []
|
||||
|
||||
n = len(transfer_resources)
|
||||
if len(from_positions) != n or len(to_positions) != n:
|
||||
raise ValueError(
|
||||
f"transfer_resources({n}), from_positions({len(from_positions)}), "
|
||||
f"to_positions({len(to_positions)}) 长度不一致"
|
||||
)
|
||||
|
||||
# 组合为内部 transfer_items 便于分批处理
|
||||
transfer_items = []
|
||||
for i in range(n):
|
||||
res = transfer_resources[i] if isinstance(transfer_resources[i], dict) else {}
|
||||
transfer_items.append({
|
||||
"resource_id": res.get("id", res.get("name", "")),
|
||||
"resource_uuid": res.get("sample_id", ""),
|
||||
"from_position": from_positions[i],
|
||||
"to_position": to_positions[i],
|
||||
"resource": res,
|
||||
})
|
||||
|
||||
# 查询 AGV 配置
|
||||
agv_cfg = find_agv_config(G)
|
||||
agv_id = agv_cfg["agv_id"]
|
||||
device_roles = agv_cfg["device_roles"]
|
||||
route_table = agv_cfg["route_table"]
|
||||
capacity = agv_cfg["capacity"]
|
||||
|
||||
if capacity <= 0:
|
||||
raise ValueError(f"AGV {agv_id} 容量为 0,请检查 Warehouse 子节点配置")
|
||||
|
||||
nav_device = device_roles.get("navigator", device_roles.get("nav"))
|
||||
arm_device = device_roles.get("arm")
|
||||
if not nav_device or not arm_device:
|
||||
raise ValueError(f"AGV {agv_id} device_roles 缺少 navigator 或 arm: {device_roles}")
|
||||
|
||||
from_repo_ = list(from_repo.values())[0]
|
||||
to_repo_ = list(to_repo.values())[0]
|
||||
from_station_id = from_repo_["id"]
|
||||
to_station_id = to_repo_["id"]
|
||||
|
||||
# 查找路由
|
||||
route_to_source = _find_route(route_table, agv_id, from_station_id)
|
||||
route_to_target = _find_route(route_table, from_station_id, to_station_id)
|
||||
|
||||
# 构建 AGV carrier 的 children dict(用于 compile 阶段状态追踪)
|
||||
agv_carrier_children: Dict[str, Any] = {}
|
||||
|
||||
# 计算 slot 名称(A01, A02, B01, ...)
|
||||
agv_slot_names = _get_agv_slot_names(G, agv_cfg)
|
||||
|
||||
# 分批
|
||||
batches = split_batches(transfer_items, capacity)
|
||||
|
||||
steps: List[Dict[str, Any]] = []
|
||||
|
||||
for batch_idx, batch in enumerate(batches):
|
||||
is_last_batch = (batch_idx == len(batches) - 1)
|
||||
|
||||
# 阶段 1: AGV 导航到来源工站
|
||||
steps.append({
|
||||
"device_id": nav_device,
|
||||
"action_name": "send_nav_task",
|
||||
"action_kwargs": {
|
||||
"command": route_to_source.get("nav_command", "")
|
||||
},
|
||||
"_comment": f"批次{batch_idx + 1}/{len(batches)}: AGV 导航至来源 {from_station_id}"
|
||||
})
|
||||
|
||||
# 阶段 2: 逐个 pick
|
||||
for item_idx, item in enumerate(batch):
|
||||
from_pos = item["from_position"]
|
||||
slot = agv_slot_names[item_idx] if item_idx < len(agv_slot_names) else f"S{item_idx + 1}"
|
||||
|
||||
# compile 阶段更新 children dict
|
||||
if from_pos in from_repo_.get("children", {}):
|
||||
resource_data = from_repo_["children"].pop(from_pos)
|
||||
resource_data["parent"] = agv_id
|
||||
agv_carrier_children[slot] = resource_data
|
||||
|
||||
steps.append({
|
||||
"device_id": arm_device,
|
||||
"action_name": "move_pos_task",
|
||||
"action_kwargs": {
|
||||
"command": route_to_source.get("arm_pick", route_to_source.get("arm_command", ""))
|
||||
},
|
||||
"_transfer_meta": {
|
||||
"phase": "pick",
|
||||
"resource_uuid": item.get("resource_uuid", ""),
|
||||
"resource_id": item.get("resource_id", ""),
|
||||
"from_parent": from_station_id,
|
||||
"from_position": from_pos,
|
||||
"agv_slot": slot,
|
||||
},
|
||||
"_comment": f"Pick {item.get('resource_id', from_pos)} → AGV.{slot}"
|
||||
})
|
||||
|
||||
# 阶段 3: AGV 导航到目标工站
|
||||
steps.append({
|
||||
"device_id": nav_device,
|
||||
"action_name": "send_nav_task",
|
||||
"action_kwargs": {
|
||||
"command": route_to_target.get("nav_command", "")
|
||||
},
|
||||
"_comment": f"批次{batch_idx + 1}: AGV 导航至目标 {to_station_id}"
|
||||
})
|
||||
|
||||
# 阶段 4: 逐个 place
|
||||
for item_idx, item in enumerate(batch):
|
||||
to_pos = item["to_position"]
|
||||
slot = agv_slot_names[item_idx] if item_idx < len(agv_slot_names) else f"S{item_idx + 1}"
|
||||
|
||||
# compile 阶段更新 children dict
|
||||
if slot in agv_carrier_children:
|
||||
resource_data = agv_carrier_children.pop(slot)
|
||||
resource_data["parent"] = to_repo_["id"]
|
||||
to_repo_["children"][to_pos] = resource_data
|
||||
|
||||
steps.append({
|
||||
"device_id": arm_device,
|
||||
"action_name": "move_pos_task",
|
||||
"action_kwargs": {
|
||||
"command": route_to_target.get("arm_place", route_to_target.get("arm_command", ""))
|
||||
},
|
||||
"_transfer_meta": {
|
||||
"phase": "place",
|
||||
"resource_uuid": item.get("resource_uuid", ""),
|
||||
"resource_id": item.get("resource_id", ""),
|
||||
"to_parent": to_station_id,
|
||||
"to_position": to_pos,
|
||||
"agv_slot": slot,
|
||||
},
|
||||
"_comment": f"Place AGV.{slot} → {to_station_id}.{to_pos}"
|
||||
})
|
||||
|
||||
# 如果还有下一批,AGV 需要返回来源取料
|
||||
if not is_last_batch:
|
||||
steps.append({
|
||||
"device_id": nav_device,
|
||||
"action_name": "send_nav_task",
|
||||
"action_kwargs": {
|
||||
"command": route_to_source.get("nav_command", "")
|
||||
},
|
||||
"_comment": f"AGV 返回来源 {from_station_id} 取下一批"
|
||||
})
|
||||
|
||||
return steps
|
||||
|
||||
|
||||
def _find_route(route_table: Dict[str, Any], from_id: str, to_id: str) -> Dict[str, str]:
|
||||
"""在路由表中查找路线,支持 A->B 和 (A, B) 两种 key 格式"""
|
||||
# 优先 "A->B" 格式
|
||||
key = f"{from_id}->{to_id}"
|
||||
if key in route_table:
|
||||
return route_table[key]
|
||||
# 兼容 tuple key(JSON 中以逗号分隔字符串表示)
|
||||
tuple_key = f"({from_id}, {to_id})"
|
||||
if tuple_key in route_table:
|
||||
return route_table[tuple_key]
|
||||
raise KeyError(f"路由表中未找到: {key},可用路线: {list(route_table.keys())}")
|
||||
|
||||
|
||||
def _get_agv_slot_names(G: nx.Graph, agv_cfg: dict) -> List[str]:
|
||||
"""从设备图中获取 AGV Warehouse 的 slot 名称列表"""
|
||||
agv_id = agv_cfg["agv_id"]
|
||||
neighbors = G.successors(agv_id) if G.is_directed() else G.neighbors(agv_id)
|
||||
for neighbor in neighbors:
|
||||
ndata = G.nodes[neighbor]
|
||||
node_type = ndata.get("type", "")
|
||||
res_content = ndata.get("res_content")
|
||||
if hasattr(res_content, "type"):
|
||||
node_type = res_content.type or node_type
|
||||
elif isinstance(res_content, dict):
|
||||
node_type = res_content.get("type", node_type)
|
||||
if node_type == "warehouse":
|
||||
config = ndata.get("config", {})
|
||||
if hasattr(res_content, "config") and isinstance(res_content.config, dict):
|
||||
config = res_content.config
|
||||
elif isinstance(res_content, dict):
|
||||
config = res_content.get("config", config)
|
||||
num_x = config.get("num_items_x", 1)
|
||||
num_y = config.get("num_items_y", 1)
|
||||
num_z = config.get("num_items_z", 1)
|
||||
# 与 warehouse_factory 一致的命名
|
||||
letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
len_x = num_x if num_z == 1 else (num_y if num_x == 1 else num_x)
|
||||
len_y = num_y if num_z == 1 else (num_z if num_x == 1 else num_z)
|
||||
return [f"{letters[j]}{i + 1:02d}" for i in range(len_x) for j in range(len_y)]
|
||||
# 兜底生成通用名称
|
||||
capacity = agv_cfg.get("capacity", 4)
|
||||
return [f"S{i + 1}" for i in range(capacity)]
|
||||
0
unilabos/devices/transport/__init__.py
Normal file
0
unilabos/devices/transport/__init__.py
Normal file
127
unilabos/devices/transport/agv_workstation.py
Normal file
127
unilabos/devices/transport/agv_workstation.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
AGV 通用转运工站 Driver
|
||||
|
||||
继承 WorkstationBase,通过 WorkstationNodeCreator 自动获得 ROS2WorkstationNode 能力。
|
||||
Warehouse 作为 children 中的资源节点,由 attach_resource() 自动注册到 resource_tracker。
|
||||
deck=None,不使用 PLR Deck 抽象。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pylabrobot.resources import Deck
|
||||
|
||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
||||
from unilabos.resources.warehouse import WareHouse
|
||||
from unilabos.utils import logger
|
||||
|
||||
|
||||
class AGVTransportStation(WorkstationBase):
|
||||
"""通用 AGV 转运工站
|
||||
|
||||
初始化链路(零框架改动):
|
||||
ROS2DeviceNode.__init__():
|
||||
issubclass(AGVTransportStation, WorkstationBase) → True
|
||||
→ WorkstationNodeCreator.create_instance(data):
|
||||
data["deck"] = None
|
||||
→ DeviceClassCreator.create_instance(data) → AGVTransportStation(deck=None, ...)
|
||||
→ attach_resource(): children 中 type="warehouse" → resource_tracker.add_resource(wh)
|
||||
→ ROS2WorkstationNode(protocol_type=[...], children=[nav, arm], ...)
|
||||
→ driver.post_init(ros_node):
|
||||
self.carrier 从 resource_tracker 中获取 WareHouse
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
deck: Optional[Deck] = None,
|
||||
children: Optional[List[Any]] = None,
|
||||
route_table: Optional[Dict[str, Dict[str, str]]] = None,
|
||||
device_roles: Optional[Dict[str, str]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(deck=None, **kwargs)
|
||||
self.route_table: Dict[str, Dict[str, str]] = route_table or {}
|
||||
self.device_roles: Dict[str, str] = device_roles or {}
|
||||
|
||||
# ============ 载具 (Warehouse) ============
|
||||
|
||||
@property
|
||||
def carrier(self) -> Optional[WareHouse]:
|
||||
"""从 resource_tracker 中找到 AGV 载具 Warehouse"""
|
||||
if not hasattr(self, "_ros_node"):
|
||||
return None
|
||||
for res in self._ros_node.resource_tracker.resources:
|
||||
if isinstance(res, WareHouse):
|
||||
return res
|
||||
return None
|
||||
|
||||
@property
|
||||
def capacity(self) -> int:
|
||||
"""AGV 载具总容量(slot 数)"""
|
||||
wh = self.carrier
|
||||
if wh is None:
|
||||
return 0
|
||||
return wh.num_items_x * wh.num_items_y * wh.num_items_z
|
||||
|
||||
@property
|
||||
def free_slots(self) -> List[str]:
|
||||
"""返回当前空闲 slot 名称列表"""
|
||||
wh = self.carrier
|
||||
if wh is None:
|
||||
return []
|
||||
ordering = getattr(wh, "_ordering", {})
|
||||
return [name for name, site in ordering.items() if site.resource is None]
|
||||
|
||||
@property
|
||||
def occupied_slots(self) -> Dict[str, Any]:
|
||||
"""返回已占用的 slot → Resource 映射"""
|
||||
wh = self.carrier
|
||||
if wh is None:
|
||||
return {}
|
||||
ordering = getattr(wh, "_ordering", {})
|
||||
return {name: site.resource for name, site in ordering.items() if site.resource is not None}
|
||||
|
||||
# ============ 路由查询 ============
|
||||
|
||||
def resolve_route(self, from_station: str, to_station: str) -> Dict[str, str]:
|
||||
"""查询路由表,返回导航和机械臂指令
|
||||
|
||||
Args:
|
||||
from_station: 来源工站 ID
|
||||
to_station: 目标工站 ID
|
||||
|
||||
Returns:
|
||||
{"nav_command": "...", "arm_pick": "...", "arm_place": "..."}
|
||||
|
||||
Raises:
|
||||
KeyError: 路由表中未找到对应路线
|
||||
"""
|
||||
route_key = f"{from_station}->{to_station}"
|
||||
if route_key not in self.route_table:
|
||||
raise KeyError(f"路由表中未找到路线: {route_key}")
|
||||
return self.route_table[route_key]
|
||||
|
||||
def get_device_id(self, role: str) -> str:
|
||||
"""获取子设备 ID
|
||||
|
||||
Args:
|
||||
role: 设备角色,如 "navigator", "arm"
|
||||
|
||||
Returns:
|
||||
设备 ID 字符串
|
||||
|
||||
Raises:
|
||||
KeyError: 未配置该角色的设备
|
||||
"""
|
||||
if role not in self.device_roles:
|
||||
raise KeyError(f"未配置设备角色: {role},当前已配置: {list(self.device_roles.keys())}")
|
||||
return self.device_roles[role]
|
||||
|
||||
# ============ 生命周期 ============
|
||||
|
||||
def post_init(self, ros_node) -> None:
|
||||
super().post_init(ros_node)
|
||||
wh = self.carrier
|
||||
if wh is not None:
|
||||
logger.info(f"AGV {ros_node.device_id} 载具已就绪: {wh.name}, 容量={self.capacity}")
|
||||
else:
|
||||
logger.warning(f"AGV {ros_node.device_id} 未发现 Warehouse 载具资源")
|
||||
368
unilabos/registry/devices/transport_agv.yaml
Normal file
368
unilabos/registry/devices/transport_agv.yaml
Normal file
@@ -0,0 +1,368 @@
|
||||
agv_transport_station:
|
||||
category:
|
||||
- work_station
|
||||
- transport_agv
|
||||
class:
|
||||
action_value_mappings:
|
||||
AGVTransferProtocol:
|
||||
feedback: {}
|
||||
goal:
|
||||
from_repo: from_repo
|
||||
from_repo_position: from_repo_position
|
||||
to_repo: to_repo
|
||||
to_repo_position: to_repo_position
|
||||
goal_default:
|
||||
from_repo:
|
||||
category: ''
|
||||
children: []
|
||||
config: ''
|
||||
data: ''
|
||||
id: ''
|
||||
name: ''
|
||||
parent: ''
|
||||
pose:
|
||||
orientation:
|
||||
w: 1.0
|
||||
x: 0.0
|
||||
y: 0.0
|
||||
z: 0.0
|
||||
position:
|
||||
x: 0.0
|
||||
y: 0.0
|
||||
z: 0.0
|
||||
sample_id: ''
|
||||
type: ''
|
||||
from_repo_position: ''
|
||||
to_repo:
|
||||
category: ''
|
||||
children: []
|
||||
config: ''
|
||||
data: ''
|
||||
id: ''
|
||||
name: ''
|
||||
parent: ''
|
||||
pose:
|
||||
orientation:
|
||||
w: 1.0
|
||||
x: 0.0
|
||||
y: 0.0
|
||||
z: 0.0
|
||||
position:
|
||||
x: 0.0
|
||||
y: 0.0
|
||||
z: 0.0
|
||||
sample_id: ''
|
||||
type: ''
|
||||
to_repo_position: ''
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: ''
|
||||
properties:
|
||||
feedback:
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
title: AGVTransfer_Feedback
|
||||
type: object
|
||||
goal:
|
||||
properties:
|
||||
from_repo:
|
||||
properties:
|
||||
category:
|
||||
type: string
|
||||
children:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
config:
|
||||
type: string
|
||||
data:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
parent:
|
||||
type: string
|
||||
pose:
|
||||
properties:
|
||||
orientation:
|
||||
properties:
|
||||
w:
|
||||
type: number
|
||||
x:
|
||||
type: number
|
||||
y:
|
||||
type: number
|
||||
z:
|
||||
type: number
|
||||
required:
|
||||
- x
|
||||
- y
|
||||
- z
|
||||
- w
|
||||
title: orientation
|
||||
type: object
|
||||
position:
|
||||
properties:
|
||||
x:
|
||||
type: number
|
||||
y:
|
||||
type: number
|
||||
z:
|
||||
type: number
|
||||
required:
|
||||
- x
|
||||
- y
|
||||
- z
|
||||
title: position
|
||||
type: object
|
||||
required:
|
||||
- position
|
||||
- orientation
|
||||
title: pose
|
||||
type: object
|
||||
sample_id:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- sample_id
|
||||
- children
|
||||
- parent
|
||||
- type
|
||||
- category
|
||||
- pose
|
||||
- config
|
||||
- data
|
||||
title: from_repo
|
||||
type: object
|
||||
from_repo_position:
|
||||
type: string
|
||||
to_repo:
|
||||
properties:
|
||||
category:
|
||||
type: string
|
||||
children:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
config:
|
||||
type: string
|
||||
data:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
parent:
|
||||
type: string
|
||||
pose:
|
||||
properties:
|
||||
orientation:
|
||||
properties:
|
||||
w:
|
||||
type: number
|
||||
x:
|
||||
type: number
|
||||
y:
|
||||
type: number
|
||||
z:
|
||||
type: number
|
||||
required:
|
||||
- x
|
||||
- y
|
||||
- z
|
||||
- w
|
||||
title: orientation
|
||||
type: object
|
||||
position:
|
||||
properties:
|
||||
x:
|
||||
type: number
|
||||
y:
|
||||
type: number
|
||||
z:
|
||||
type: number
|
||||
required:
|
||||
- x
|
||||
- y
|
||||
- z
|
||||
title: position
|
||||
type: object
|
||||
required:
|
||||
- position
|
||||
- orientation
|
||||
title: pose
|
||||
type: object
|
||||
sample_id:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- sample_id
|
||||
- children
|
||||
- parent
|
||||
- type
|
||||
- category
|
||||
- pose
|
||||
- config
|
||||
- data
|
||||
title: to_repo
|
||||
type: object
|
||||
to_repo_position:
|
||||
type: string
|
||||
required:
|
||||
- from_repo
|
||||
- from_repo_position
|
||||
- to_repo
|
||||
- to_repo_position
|
||||
title: AGVTransfer_Goal
|
||||
type: object
|
||||
result:
|
||||
properties:
|
||||
return_info:
|
||||
type: string
|
||||
success:
|
||||
type: boolean
|
||||
required:
|
||||
- return_info
|
||||
- success
|
||||
title: AGVTransfer_Result
|
||||
type: object
|
||||
required:
|
||||
- goal
|
||||
title: AGVTransfer
|
||||
type: object
|
||||
type: AGVTransfer
|
||||
BatchTransferProtocol:
|
||||
feedback: {}
|
||||
goal:
|
||||
from_positions: from_positions
|
||||
from_repo: from_repo
|
||||
to_positions: to_positions
|
||||
to_repo: to_repo
|
||||
transfer_resources: transfer_resources
|
||||
goal_default:
|
||||
from_positions: []
|
||||
from_repo:
|
||||
category: ''
|
||||
children: []
|
||||
config: ''
|
||||
data: ''
|
||||
id: ''
|
||||
name: ''
|
||||
parent: ''
|
||||
pose:
|
||||
orientation:
|
||||
w: 1.0
|
||||
x: 0.0
|
||||
y: 0.0
|
||||
z: 0.0
|
||||
position:
|
||||
x: 0.0
|
||||
y: 0.0
|
||||
z: 0.0
|
||||
sample_id: ''
|
||||
type: ''
|
||||
to_positions: []
|
||||
to_repo:
|
||||
category: ''
|
||||
children: []
|
||||
config: ''
|
||||
data: ''
|
||||
id: ''
|
||||
name: ''
|
||||
parent: ''
|
||||
pose:
|
||||
orientation:
|
||||
w: 1.0
|
||||
x: 0.0
|
||||
y: 0.0
|
||||
z: 0.0
|
||||
position:
|
||||
x: 0.0
|
||||
y: 0.0
|
||||
z: 0.0
|
||||
sample_id: ''
|
||||
type: ''
|
||||
transfer_resources: []
|
||||
handles: {}
|
||||
result: {}
|
||||
schema:
|
||||
description: AGV 批量物料转运协议
|
||||
properties:
|
||||
feedback: {}
|
||||
goal:
|
||||
properties:
|
||||
from_positions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
from_repo:
|
||||
type: object
|
||||
to_positions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
to_repo:
|
||||
type: object
|
||||
transfer_resources:
|
||||
items:
|
||||
type: object
|
||||
type: array
|
||||
required:
|
||||
- from_repo
|
||||
- to_repo
|
||||
- transfer_resources
|
||||
- from_positions
|
||||
- to_positions
|
||||
type: object
|
||||
result: {}
|
||||
required:
|
||||
- goal
|
||||
title: BatchTransferProtocol
|
||||
type: object
|
||||
type: BatchTransfer
|
||||
module: unilabos.devices.transport.agv_workstation:AGVTransportStation
|
||||
status_types:
|
||||
agv_status: String
|
||||
carrier_status: String
|
||||
type: python
|
||||
config_info: []
|
||||
description: 通用 AGV 转运工站。继承 WorkstationBase,通过 Warehouse 子资源管理物料中转态。支持单物料和批量物料转运协议,路由表和子设备配置全部由
|
||||
JSON 驱动。
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema:
|
||||
config:
|
||||
properties:
|
||||
device_roles:
|
||||
properties:
|
||||
arm:
|
||||
type: string
|
||||
navigator:
|
||||
type: string
|
||||
type: object
|
||||
protocol_type:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
route_table:
|
||||
type: object
|
||||
type: object
|
||||
data:
|
||||
properties:
|
||||
agv_status:
|
||||
type: string
|
||||
carrier_status:
|
||||
type: string
|
||||
type: object
|
||||
version: 1.0.0
|
||||
@@ -577,17 +577,38 @@
|
||||
{
|
||||
"id": "AGV",
|
||||
"name": "AGV",
|
||||
"children": ["zhixing_agv", "zhixing_ur_arm"],
|
||||
"children": ["zhixing_agv", "zhixing_ur_arm", "agv_platform"],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "workstation",
|
||||
"class": "agv_transport_station",
|
||||
"position": {
|
||||
"x": 698.1111111111111,
|
||||
"y": 478,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"protocol_type": ["AGVTransferProtocol"]
|
||||
"protocol_type": ["AGVTransferProtocol", "BatchTransferProtocol"],
|
||||
"device_roles": {
|
||||
"navigator": "zhixing_agv",
|
||||
"arm": "zhixing_ur_arm"
|
||||
},
|
||||
"route_table": {
|
||||
"AiChemEcoHiWo->Revvity": {
|
||||
"nav_command": "{\"target\": \"LM14\"}",
|
||||
"arm_pick": "{\"task_name\": \"camera/250111_biaozhi.urp\"}",
|
||||
"arm_place": "{\"task_name\": \"camera/250111_put_board.urp\"}"
|
||||
},
|
||||
"Revvity->HPLC": {
|
||||
"nav_command": "{\"target\": \"LM13\"}",
|
||||
"arm_pick": "{\"task_name\": \"camera/250111_lfp.urp\"}",
|
||||
"arm_place": "{\"task_name\": \"camera/250111_hplc.urp\"}"
|
||||
},
|
||||
"HPLC->Revvity": {
|
||||
"nav_command": "{\"target\": \"LM13\"}",
|
||||
"arm_pick": "{\"task_name\": \"camera/250111_hplc.urp\"}",
|
||||
"arm_place": "{\"task_name\": \"camera/250111_lfp.urp\"}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
}
|
||||
@@ -627,6 +648,27 @@
|
||||
},
|
||||
"data": {
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "agv_platform",
|
||||
"name": "agv_platform",
|
||||
"children": [],
|
||||
"parent": "AGV",
|
||||
"type": "warehouse",
|
||||
"class": "",
|
||||
"position": {
|
||||
"x": 698.1111111111111,
|
||||
"y": 478,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"name": "agv_platform",
|
||||
"num_items_x": 2,
|
||||
"num_items_y": 1,
|
||||
"num_items_z": 1
|
||||
},
|
||||
"data": {
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
|
||||
@@ -96,6 +96,7 @@ set(action_files
|
||||
|
||||
"action/WorkStationRun.action"
|
||||
"action/AGVTransfer.action"
|
||||
"action/BatchTransfer.action"
|
||||
|
||||
"action/DispenStationSolnPrep.action"
|
||||
"action/DispenStationVialFeed.action"
|
||||
|
||||
11
unilabos_msgs/action/BatchTransfer.action
Normal file
11
unilabos_msgs/action/BatchTransfer.action
Normal file
@@ -0,0 +1,11 @@
|
||||
# 批量物料转运
|
||||
Resource from_repo
|
||||
Resource to_repo
|
||||
Resource[] transfer_resources
|
||||
string[] from_positions
|
||||
string[] to_positions
|
||||
---
|
||||
string return_info
|
||||
bool success
|
||||
---
|
||||
string status
|
||||
Reference in New Issue
Block a user