简介:本资源是一份面向高校学生与自动化初学者的多AGV协同路径规划实践项目,聚焦Python算法实现,适用于课程设计、毕业设计及工程实训等场景。项目以轻量级代码实现为核心,共包含3个Python源文件,总大小仅3KB,涵盖地图生成、路径点建模与核心规划逻辑等关键模块,代码结构清晰、注释充分,便于理解算法原理与快速二次开发。已有790人学习下载,体现了其在教学实践中的实用价值。读者可直接运行并调试完整流程,掌握基于图搜索或启发式策略的多AGV避障与调度思路;代码模块解耦合理,支持替换不同地图、调整AGV数量及扩展冲突检测机制,为后续深入研究分布式调度或引入强化学习奠定基础。
1. 多AGV路径规划不是“多个单体A*拼起来”——它本质是时空冲突消解问题
当你在仓库调度系统里看到三台AGV小车同时启动,却没发生死锁、没反复绕路、也没某台被卡在路口等30秒——这背后不是靠运气,也不是把单台小车的A算法简单复制三份就能实现的。真实产线中,多AGV协同的核心矛盾在于:路径在空间上重叠、在时间上冲突、在资源(如交叉路口、充电位、装卸区)上竞争。Python之所以成为该领域研究落地的首选,并非因为“语法简单”,而是其生态能快速验证算法逻辑(NetworkX建模图结构)、可视化冲突(Matplotlib动态帧)、对接仿真环境(simpy离散事件模拟)、甚至桥接工业协议(pymodbus/OPC UA)。本文面向已掌握基础图搜索(如A、Dijkstra)和Python数据结构的工程师,聚焦“如何用纯Python从零构建可复现、可调试、可扩展的多AGV路径规划最小可行系统”——不依赖ROS、不调用商业调度引擎、不假设已有地图SDK,所有代码均可在Python 3.9+标准环境中直接运行,重点讲清冲突检测时机、等待策略选择、重规划触发条件这三个工业现场最常出错的环节。
2. 构建带时空维度的AGV路网模型:用NetworkX定义节点、边与通行约束
多AGV路径规划的第一道门槛,是把物理厂区抽象成可计算的时空图。很多初学者直接套用静态栅格地图做A*,结果在十字路口频繁碰撞——问题不在算法本身,而在模型缺失“时间”这一关键维度。我们采用分层建模:底层是空间拓扑(NetworkX Graph),上层叠加时间窗约束(Time-Expanded Graph思想),但不真正展开时间轴(避免维度爆炸),而是用“边属性+节点状态缓存”实现轻量级时空推理。
2.1 定义AGV路网的节点与边:支持单向/双向/限速/占用时长
我们使用NetworkX的DiGraph(有向图)而非Graph,因为AGV车道存在单行限制(如窄通道)、优先级差异(主干道允许双向,支路仅单向)。每条边必须携带两个核心属性:weight(空间距离,用于A*启发式)和duration(以秒为单位的典型通行耗时,用于时间冲突预判)。
import networkx as nx import matplotlib.pyplot as plt # 创建有向图 G = nx.DiGraph() # 添加节点:(x, y)坐标 + 类型标签('junction'/'aisle'/'charger') G.add_node((0, 0), type='junction', name='J1') G.add_node((10, 0), type='aisle', name='A1') G.add_node((10, 5), type='junction', name='J2') G.add_node((20, 5), type='aisle', name='A2') # 添加有向边:source -> target,附带空间距离和通行耗时 G.add_edge((0, 0), (10, 0), weight=10.0, duration=8.0) # J1→A1,10米,需8秒 G.add_edge((10, 0), (10, 5), weight=5.0, duration=4.0) # A1→J2,5米,需4秒 G.add_edge((10, 5), (20, 5), weight=10.0, duration=8.0) # J2→A2,10米,需8秒 # 注意:反向边需单独添加,体现单向性 G.add_edge((10, 5), (10, 0), weight=5.0, duration=4.0) # J2→A1(允许返程)提示:
weight必须是欧氏距离或曼哈顿距离(影响A*的h(n)计算精度),而duration应基于实测AGV速度(如1.2m/s)和加减速曲线拟合得出,不能简单用distance/speed。例如10米直道,若含2秒加速+匀速段+2秒减速,实际耗时可能比8.3秒更长。
2.2 为每个节点注入“时空占用表”:解决路口死锁的关键
单纯用图搜索得到路径后直接下发,必然在交汇点(如J2)发生死锁。解决方案是在每个关键节点(类型为junction或charger)上维护一个occupancy_schedule列表,记录未来一段时间内被各AGV占用的起止时间戳。这不是全局时间窗展开,而是按需查询的局部缓存:
# 为节点添加初始占用表(空列表) for node in G.nodes(): G.nodes[node]['occupancy_schedule'] = [] # 示例:模拟AGV0在t=12.0~20.0秒占用J2节点 j2_occupancy = G.nodes[(10, 5)]['occupancy_schedule'] j2_occupancy.append({'agv_id': 0, 'start': 12.0, 'end': 20.0, 'purpose': 'crossing'}) # 冲突检测函数:检查某AGV在指定时间窗内能否安全通过节点 def can_occupy_node(G, node, agv_id, start_t, end_t, tolerance=0.5): """ tolerance: 允许的时间重叠缓冲(秒),避免浮点误差误判 返回True表示无冲突,可安全预约 """ schedule = G.nodes[node].get('occupancy_schedule', []) for record in schedule: # 检查时间区间是否重叠:[start_t, end_t] 与 [record['start'], record['end']] if not (end_t <= record['start'] - tolerance or start_t >= record['end'] + tolerance): return False # 发现重叠,冲突 return True # 测试:AGV1想在t=18.0~26.0秒通过J2?此时与AGV0的12~20秒占用重叠(18~20),返回False print(can_occupy_node(G, (10, 5), 1, 18.0, 26.0)) # 输出: False注意:此设计将“冲突检测”从中心化全局调度器下放到每个节点本地,大幅降低通信开销。实际部署时,
occupancy_schedule需通过轻量消息(如ZeroMQ PUB/SUB)在AGV间同步,但本研究阶段先用内存共享模拟。
2.3 可视化路网与动态占用状态:用Matplotlib实时渲染冲突点
调试多AGV系统,静态图不够用。我们编写一个函数,在每次路径规划后,用不同颜色标出当前被占用的节点(红色)和空闲节点(绿色),并显示占用时间段:
def plot_network_with_occupancy(G, title="AGV Network State"): plt.figure(figsize=(10, 6)) pos = {node: node for node in G.nodes()} # 直接用坐标作布局 # 绘制所有节点 node_colors = [] node_labels = {} for node in G.nodes(): occ_list = G.nodes[node]['occupancy_schedule'] if occ_list: node_colors.append('red') # 占用中 # 显示最早占用的起止时间 earliest = min(occ_list, key=lambda x: x['start']) node_labels[node] = f"{G.nodes[node]['name']}\n{earliest['start']:.1f}-{earliest['end']:.1f}s" else: node_colors.append('lightgreen') # 空闲 node_labels[node] = G.nodes[node]['name'] nx.draw_networkx_nodes(G, pos, node_color=node_colors, node_size=800, alpha=0.8) nx.draw_networkx_labels(G, pos, node_labels, font_size=9) nx.draw_networkx_edges(G, pos, edge_color='gray', width=2, arrows=True, arrowsize=15) plt.title(title) plt.axis('equal') plt.show() # 调用示例(需在交互环境如Jupyter中运行) # plot_network_with_occupancy(G, "t=15.0s: J2 occupied by AGV0")此可视化能一眼识别瓶颈节点(长期红标)和虚假冲突(短暂重叠但AGV实际可微调速度避开),是算法调优不可替代的调试手段。
3. 实现带冲突回退的A*算法:为每台AGV生成时空可行路径
单台AGV的A只需考虑空间距离,而多AGV场景下,A的代价函数必须融合空间代价与时间冲突惩罚。我们不修改A主循环,而是在get_neighbors()和heuristic()环节注入时空约束,形成“冲突感知A”。
3.1 扩展A*节点状态:从(x,y)到(x,y,t)的时空坐标
传统A*状态是二维坐标,多AGV需升维为三维(x, y, t),其中t是预计到达该节点的绝对时间(秒)。这意味着同一物理位置(10,5)在t=12和t=25是两个不同节点。但为避免状态爆炸,我们不预生成所有时间点,而是在搜索过程中按需计算下一个可能的到达时间。
import heapq from typing import List, Tuple, Optional, Dict, Any def conflict_aware_astar( G: nx.DiGraph, start: Tuple[float, float], goal: Tuple[float, float], agv_id: int, current_time: float = 0.0, max_expansion: int = 10000 ) -> Optional[List[Tuple[float, float, float]]]: """ 返回路径列表,每个元素为(x, y, arrival_time) """ # 优先队列:(f_score, g_score, node, time, path) open_set = [] heapq.heappush(open_set, (0.0, 0.0, start, current_time, [(*start, current_time)])) # 记录已访问状态:key为(x,y,t_rounded),避免重复扩展相近时间点 closed_set = set() while open_set and len(closed_set) < max_expansion: f_score, g_score, current_node, current_t, path = heapq.heappop(open_set) # 时间离散化:四舍五入到0.1秒,减少状态数 state_key = (*current_node, round(current_t, 1)) if state_key in closed_set: continue closed_set.add(state_key) # 到达目标节点(空间上) if current_node == goal: return path # 遍历邻居:只考虑从current_node出发的有向边 for neighbor in G.successors(current_node): edge_data = G[current_node][neighbor] distance = edge_data['weight'] duration = edge_data['duration'] next_t = current_t + duration # 关键:冲突检测!检查neighbor节点在[next_t, next_t+0.1]窗口是否可占用 # 这里简化为检查next_t时刻是否冲突(实际应检查整个占用时段) if not can_occupy_node(G, neighbor, agv_id, next_t, next_t + 0.1): # 冲突:插入等待,使到达时间延后至首个空闲时刻 next_t = find_next_available_time(G, neighbor, agv_id, next_t) # 计算新路径 new_path = path + [(neighbor[0], neighbor[1], next_t)] new_g = g_score + distance new_h = euclidean_distance(neighbor, goal) # 启发式:欧氏距离 new_f = new_g + new_h heapq.heappush(open_set, (new_f, new_g, neighbor, next_t, new_path)) return None # 未找到路径 def euclidean_distance(p1: Tuple[float, float], p2: Tuple[float, float]) -> float: return ((p1[0]-p2[0])**2 + (p1[1]-p2[1])**2)**0.5 def find_next_available_time(G, node, agv_id, base_time, max_wait=30.0): """线性搜索下一个可用时间点(实际项目中建议用二分查找占用表)""" t = base_time while t < base_time + max_wait: if can_occupy_node(G, node, agv_id, t, t + 0.1): return t t += 0.5 # 步进0.5秒 return base_time + max_wait # 超时,强制占用(应触发告警)逻辑说明:当A尝试扩展到一个邻居节点时,
can_occupy_node()会检查该节点在预计到达时间next_t附近是否有冲突。若有,则调用find_next_available_time()跳过冲突时段,将next_t更新为首个空闲时刻。这相当于在A搜索树中动态插入等待动作,无需额外的状态节点。
3.2 为AGV0规划首条路径并预约资源
现在用上述算法为AGV0从(0,0)到(20,5)规划路径,并将占用记录写入图中:
# 规划AGV0路径 path_agv0 = conflict_aware_astar(G, (0,0), (20,5), agv_id=0, current_time=0.0) print("AGV0 Path:", path_agv0) # 输出示例: [ (0.0,0.0,0.0), (10.0,0.0,8.0), (10.0,5.0,12.0), (20.0,5.0,20.0) ] # 将路径占用写入图节点 if path_agv0: for i, (x, y, t) in enumerate(path_agv0): if i > 0: # 跳过起点(通常不占用) node = (x, y) # 计算在该节点的停留/通过时间(简化:取边duration的一半作为节点驻留) dwell_time = 0.5 * G[path_agv0[i-1][0:2]][node]['duration'] if i > 0 else 0.0 G.nodes[node]['occupancy_schedule'].append({ 'agv_id': 0, 'start': t - dwell_time, 'end': t + dwell_time, 'purpose': 'transit' })3.3 参数表:影响路径质量与计算效率的5个关键参数
| 参数名 | 类型 | 默认值 | 作用说明 | 调优建议 |
|---|---|---|---|---|
max_expansion | int | 10000 | A*搜索最大扩展节点数 | 厂区越大,需增大;超时则降为5000并启用备选算法(如Theta*) |
tolerance | float | 0.5 | 冲突检测时间容差(秒) | AGV定位精度±0.1m时,设0.3;若用UWB定位,可降至0.1 |
dwell_time_factor | float | 0.5 | 节点驻留时间占边耗时比例 | 十字路口设0.8,直道设0.2;避免过度预约导致资源浪费 |
wait_step | float | 0.5 | find_next_available_time步进时间(秒) | 小于AGV控制周期(如0.1s)无意义,大于1.0秒易错过短空闲窗 |
max_wait | float | 30.0 | 单次等待上限(秒) | 超过此值应触发重规划或人工干预,防止系统僵死 |
这些参数不是固定值,而是在仿真中通过plot_network_with_occupancy()观察节点红标持续时间、路径总耗时、重规划次数三个指标联合调优。
4. 多AGV协同调度框架:基于事件驱动的路径重规划与冲突仲裁
当多台AGV并行运行,仅靠单次A*无法应对动态变化:AGV故障停驶、任务临时插入、传感器误检障碍物。我们必须构建一个中央协调器(Coordinator),它不直接控制小车,而是监听事件、触发重规划、仲裁资源争用。Python的simpy库是实现此逻辑的理想选择——它提供精确的离散事件仿真能力,且API简洁。
4.1 用simpy构建AGV实体与事件循环
每个AGV被建模为simpy.Process,其生命周期包含:接收任务→规划路径→沿路径移动→到达目标→报告状态。关键是在移动过程中,定期检查前方节点是否被其他AGV占用,并触发重规划。
import simpy import random class AGV: def __init__(self, env, G, agv_id, speed_mps=1.2): self.env = env self.G = G self.id = agv_id self.speed = speed_mps self.current_pos = (0.0, 0.0) # 初始位置 self.path = [] # 当前路径:[(x,y,t), ...] self.task_queue = [] # 待执行任务列表 def run(self): while True: if not self.task_queue: yield self.env.timeout(1.0) # 空闲等待 continue task = self.task_queue.pop(0) # 规划到task目标点的路径 self.path = conflict_aware_astar( self.G, self.current_pos, task['goal'], agv_id=self.id, current_time=self.env.now ) if not self.path: print(f"[t={self.env.now:.1f}] AGV{self.id} failed to plan path to {task['goal']}") yield self.env.timeout(5.0) # 错误等待 continue # 执行路径:逐段移动 for i in range(1, len(self.path)): prev_x, prev_y, _ = self.path[i-1] curr_x, curr_y, target_t = self.path[i] distance = euclidean_distance((prev_x, prev_y), (curr_x, curr_y)) required_time = distance / self.speed # 检查是否能在target_t到达:若当前时间已晚于target_t,需加速或重规划 if self.env.now > target_t + 1.0: # 宽容1秒 print(f"[t={self.env.now:.1f}] AGV{self.id} delayed at {(prev_x,prev_y)}->({curr_x},{curr_y})") # 触发局部重规划(仅重算后续段) self.path = self.replan_from_index(i) continue # 移动耗时 yield self.env.timeout(required_time) self.current_pos = (curr_x, curr_y) # 更新图中节点占用(此处简化,实际应由Coordinator统一管理) self.update_occupancy(curr_x, curr_y, self.env.now, required_time) print(f"[t={self.env.now:.1f}] AGV{self.id} reached {task['goal']}") def replan_from_index(self, start_idx): """从路径索引start_idx开始重规划剩余路径""" if start_idx >= len(self.path): return self.path last_node = self.path[start_idx-1][0:2] goal = self.path[-1][0:2] return conflict_aware_astar( self.G, last_node, goal, self.id, self.env.now ) def update_occupancy(self, x, y, now, duration): """更新节点占用表(简化版)""" node = (x, y) if node in self.G.nodes(): self.G.nodes[node]['occupancy_schedule'].append({ 'agv_id': self.id, 'start': now, 'end': now + duration, 'purpose': 'moving' }) # 初始化仿真环境 env = simpy.Environment() G_sim = G.copy() # 使用独立图副本进行仿真 agv0 = AGV(env, G_sim, 0) agv1 = AGV(env, G_sim, 1) # 添加初始任务 agv0.task_queue.append({'goal': (20, 5)}) agv1.task_queue.append({'goal': (0, 0)}) # 启动AGV进程 env.process(agv0.run()) env.process(agv1.run()) # 运行仿真100秒 env.run(until=100.0)4.2 冲突仲裁策略:三种等待模式的代码实现与适用场景
当两台AGV同时申请同一资源(如J2路口),Coordinator必须决策谁先通过。我们实现三种经典策略,并用字典配置切换:
class Coordinator: def __init__(self, G): self.G = G def arbitrate_junction(self, junction_node, requests: List[Dict]): """ requests: [{'agv_id':0, 'arrival_t':12.0, 'duration':3.0}, ...] 返回排序后的列表,index 0为优先通行者 """ strategy = 'priority_based' # 可选: 'fifo', 'shortest_duration', 'priority_based' if strategy == 'fifo': return sorted(requests, key=lambda x: x['arrival_t']) elif strategy == 'shortest_duration': return sorted(requests, key=lambda x: x['duration']) elif strategy == 'priority_based': # 假设AGV0有更高业务优先级(如运输电池) priority_map = {0: 10, 1: 5, 2: 1} return sorted(requests, key=lambda x: -priority_map.get(x['agv_id'], 0)) return requests # 使用示例 coord = Coordinator(G) requests = [ {'agv_id': 0, 'arrival_t': 12.0, 'duration': 3.0}, {'agv_id': 1, 'arrival_t': 12.5, 'duration': 2.0} ] ordered = coord.arbitrate_junction((10,5), requests) print("Arbitration result:", ordered) # 输出: [{'agv_id': 0, 'arrival_t': 12.0, 'duration': 3.0}, ...]场景匹配建议:
fifo:适用于任务紧急度一致的普通仓储;shortest_duration:适合高频次、短途搬运(如电商分拣),减少路口平均等待;priority_based:必须用于有严格SLA的场景(如半导体厂晶圆传输),需与MES系统集成优先级字段。
4.3 动态重规划触发器:监控3类事件并自动响应
硬编码的路径在真实世界必然失效。Coordinator需监听以下事件并触发重规划:
| 事件类型 | 检测方式 | 响应动作 | Python实现要点 |
|---|---|---|---|
| 前方节点被长期占用 | can_occupy_node()连续3次失败 | 向AGV发送REPLAN_AHEAD指令 | 在AGV的run()循环中加入if check_blockage(): trigger_replan() |
| AGV报告定位偏移>0.5m | 接收AGV上报的GPS/UWB坐标与路径预期坐标偏差 | 插入校正点,重算后续路径 | scipy.interpolate拟合新路径段 |
| 新高优先级任务插入 | Coordinator收到INSERT_TASK消息 | 中断当前AGV,为其规划新路径 | 用simpy.Interrupt打断AGV进程 |
这些触发器共同构成系统的“自愈能力”,是区别于学术Demo与工业落地的核心标志。
5. 验证路径规划效果:用3个量化指标评估算法鲁棒性
再精巧的算法,若无法量化其价值,就只是玩具。我们定义三个可测量、可对比、可归因的指标,全部用Python原生库计算,无需第三方仿真平台。
5.1 指标1:时空冲突率(Spatial-Temporal Conflict Rate)
这是最核心指标,定义为所有AGV在所有时间步中,因路径重叠导致的强制等待总时长,占AGV总运行时长的比例。低于5%为优秀,15%以上需优化路网或调度策略。
def calculate_conflict_rate(agv_logs: List[List[Dict]]) -> float: """ agv_logs: 每个AGV的轨迹日志,格式为[{'t':0.0,'pos':(0,0)}, {'t':1.2,'pos':(1.5,0)}, ...] 返回冲突率(0.0~1.0) """ total_wait_time = 0.0 total_active_time = 0.0 # 对每个AGV日志,计算其相邻点间的时间间隔 for log in agv_logs: if len(log) < 2: continue for i in range(1, len(log)): dt = log[i]['t'] - log[i-1]['t'] # 若dt显著大于理论时间(如>1.5倍),视为等待 dist = euclidean_distance(log[i-1]['pos'], log[i]['pos']) theoretical_dt = dist / 1.2 # 假设匀速1.2m/s if dt > theoretical_dt * 1.5: total_wait_time += (dt - theoretical_dt) total_active_time += log[-1]['t'] - log[0]['t'] return total_wait_time / (total_active_time + 1e-6) # 防除零 # 示例日志(模拟数据) log_agv0 = [{'t':0.0,'pos':(0,0)}, {'t':8.0,'pos':(10,0)}, {'t':12.0,'pos':(10,5)}, {'t':20.0,'pos':(20,5)}] log_agv1 = [{'t':2.0,'pos':(20,5)}, {'t':10.0,'pos':(10,5)}, {'t':14.0,'pos':(10,0)}, {'t':22.0,'pos':(0,0)}] conflict_rate = calculate_conflict_rate([log_agv0, log_agv1]) print(f"Conflict Rate: {conflict_rate*100:.1f}%")5.2 指标2:路径长度膨胀比(Path Length Inflation Ratio)
衡量算法为避让付出的空间代价。定义为实际行驶路径总长 ÷ 所有任务起点到终点的直线距离总和。理想值为1.0,超过1.3说明路网设计或冲突策略过于保守。
def calculate_inflation_ratio(agv_paths: List[List[Tuple[float,float,float]]], task_goals: List[Tuple[float,float]]) -> float: actual_total = 0.0 ideal_total = 0.0 for i, path in enumerate(agv_paths): if not path: continue # 计算实际路径长度(累加相邻点距离) for j in range(1, len(path)): p1 = path[j-1][0:2] p2 = path[j][0:2] actual_total += euclidean_distance(p1, p2) # 理想距离:起点到任务目标点 start = path[0][0:2] if path else (0,0) goal = task_goals[i] if i < len(task_goals) else (0,0) ideal_total += euclidean_distance(start, goal) return actual_total / (ideal_total + 1e-6) # 示例 paths = [path_agv0, [(20,5,2.0),(10,5,10.0),(10,0,14.0),(0,0,22.0)]] goals = [(20,5), (0,0)] inflation = calculate_inflation_ratio(paths, goals) print(f"Inflation Ratio: {inflation:.2f}x")5.3 指标3:重规划频次(Replanning Frequency)
反映系统对动态干扰的敏感度。定义为单位时间内(每小时)所有AGV触发重规划的总次数。稳定系统应<3次/小时/AGV;若>10次,说明路网节点容量不足或冲突检测过于激进。
def count_replanning_events(simulation_log: str) -> int: """ 解析仿真日志文件,统计'REPLAN'关键词出现次数 """ with open(simulation_log, 'r') as f: return sum(1 for line in f if 'REPLAN' in line) # 在仿真中记录日志 def log_event(message): with open("agv_simulation.log", "a") as f: f.write(f"[t={simpy.Environment().now:.1f}] {message}\n") # 使用:log_event("AGV0 REPLAN due to obstacle at (10,5)")这三个指标构成闭环验证铁三角:冲突率看稳定性,膨胀比看经济性,重规划频次看鲁棒性。每次算法参数调整后,必须重新运行仿真并输出这三项数值,用表格对比迭代效果——这才是工程化研究的正确姿势。
本文还有配套的精品资源,点击获取