From ed80d786c112826decdee46cc38621cc3b9c3bd0 Mon Sep 17 00:00:00 2001 From: Junhan Chang Date: Wed, 25 Mar 2026 13:10:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9EAGV=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E7=89=A9=E6=96=99=E8=BD=AC=E8=BF=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加AGV工作站设备驱动、注册表定义、批量转运编译器和消息定义。 包含跨工作站批量转运协议、AGV路径规划、容量分批等功能。 Co-Authored-By: Claude Opus 4.6 --- unilabos/app/main.py | 9 +- unilabos/compile/__init__.py | 2 + unilabos/compile/_agv_utils.py | 127 ++++++ unilabos/compile/agv_transfer_protocol.py | 41 +- unilabos/compile/batch_transfer_protocol.py | 228 +++++++++++ unilabos/devices/transport/__init__.py | 0 unilabos/devices/transport/agv_workstation.py | 127 ++++++ unilabos/registry/devices/transport_agv.yaml | 368 ++++++++++++++++++ unilabos/test/experiments/workshop.json | 48 ++- unilabos_msgs/CMakeLists.txt | 1 + unilabos_msgs/action/BatchTransfer.action | 11 + 11 files changed, 938 insertions(+), 24 deletions(-) create mode 100644 unilabos/compile/_agv_utils.py create mode 100644 unilabos/compile/batch_transfer_protocol.py create mode 100644 unilabos/devices/transport/__init__.py create mode 100644 unilabos/devices/transport/agv_workstation.py create mode 100644 unilabos/registry/devices/transport_agv.yaml create mode 100644 unilabos_msgs/action/BatchTransfer.action diff --git a/unilabos/app/main.py b/unilabos/app/main.py index 6c097682..a7ebcb0c 100644 --- a/unilabos/app/main.py +++ b/unilabos/app/main.py @@ -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]] diff --git a/unilabos/compile/__init__.py b/unilabos/compile/__init__.py index 51ca9a2d..fcd4641b 100644 --- a/unilabos/compile/__init__.py +++ b/unilabos/compile/__init__.py @@ -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, diff --git a/unilabos/compile/_agv_utils.py b/unilabos/compile/_agv_utils.py new file mode 100644 index 00000000..a9e40e58 --- /dev/null +++ b/unilabos/compile/_agv_utils.py @@ -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), + } diff --git a/unilabos/compile/agv_transfer_protocol.py b/unilabos/compile/agv_transfer_protocol.py index 18c28b6b..aec1cfb8 100644 --- a/unilabos/compile/agv_transfer_protocol.py +++ b/unilabos/compile/agv_transfer_protocol.py @@ -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", "")) } } ] diff --git a/unilabos/compile/batch_transfer_protocol.py b/unilabos/compile/batch_transfer_protocol.py new file mode 100644 index 00000000..35c77b0f --- /dev/null +++ b/unilabos/compile/batch_transfer_protocol.py @@ -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)] diff --git a/unilabos/devices/transport/__init__.py b/unilabos/devices/transport/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unilabos/devices/transport/agv_workstation.py b/unilabos/devices/transport/agv_workstation.py new file mode 100644 index 00000000..34263cae --- /dev/null +++ b/unilabos/devices/transport/agv_workstation.py @@ -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 载具资源") diff --git a/unilabos/registry/devices/transport_agv.yaml b/unilabos/registry/devices/transport_agv.yaml new file mode 100644 index 00000000..3a56b511 --- /dev/null +++ b/unilabos/registry/devices/transport_agv.yaml @@ -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 diff --git a/unilabos/test/experiments/workshop.json b/unilabos/test/experiments/workshop.json index 60d731d0..51b8776d 100644 --- a/unilabos/test/experiments/workshop.json +++ b/unilabos/test/experiments/workshop.json @@ -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": [ diff --git a/unilabos_msgs/CMakeLists.txt b/unilabos_msgs/CMakeLists.txt index 46028fd9..00dfca10 100644 --- a/unilabos_msgs/CMakeLists.txt +++ b/unilabos_msgs/CMakeLists.txt @@ -96,6 +96,7 @@ set(action_files "action/WorkStationRun.action" "action/AGVTransfer.action" + "action/BatchTransfer.action" "action/DispenStationSolnPrep.action" "action/DispenStationVialFeed.action" diff --git a/unilabos_msgs/action/BatchTransfer.action b/unilabos_msgs/action/BatchTransfer.action new file mode 100644 index 00000000..207b85b4 --- /dev/null +++ b/unilabos_msgs/action/BatchTransfer.action @@ -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