前两天整理电力系统调度相关的代码库,翻出一个挺有意思的交叉题目:电动汽车、主动配电网与电力系统规划,外加多智能体领域常用的Voronoi图围捕算法,最后落到Matlab复现上。这四个词拆开看各有各的圈子,但放在一起其实是一条很清晰的技术链条:先理解电动汽车大规模接入后主动配电网面临的新挑战,再用Voronoi图完成空间划分,最后设计一套类“围捕”的动态调度策略去实时优化负荷分配。
这篇文章就是完整复现这套思路的记录。适合两类人看:一类是做配电网规划、充电桩选址、主动配电网调度的工程师,另一类是研究多智能体协同控制、想找一个真实工程落地场景的研究生。不夸张地说,这个组合能同时回答“充电站该建在哪”和“充电负荷该往哪引”两个问题——Voronoi图告诉你空间怎么切,围捕算法告诉你每个区域内的需求怎么动态优化配置。我会把原理、代码框架、参数标定逻辑和调试中踩过的坑全部理一遍,尽量做到照着走就能跑通。
1. 项目核心思路拆解:跨界题目如何拧成一股绳
1.1 标题里四个关键词,是怎么串联起来的
这个题目把“电动汽车”“主动配电网”“电力系统规划”和“Voronoi图围捕算法”放在一起,乍一看像硬凑概念。但如果你真的在配电网规划或负荷调度一线待过,就会明白这四个词其实是同一条链条上的环节。
电动汽车是最典型的高不确定性分布式负荷。大量电动车集中充电会让配电网的峰谷差进一步拉大,尤其傍晚下班后的充电高峰,和居民生活用电高峰叠在一起,对变压器和馈线都是实实在在的压力。主动配电网和传统配电网的核心差异,在于它不再被动地“等负荷来”,而是主动去引导、调度、平衡分布式资源,包括电动车充电、分布式光伏、储能装置等等。电力系统规划要回答的则是“充电站在哪里建、容量配多大”这类空间决策问题。而Voronoi图围捕算法,正好提供了一个把空间划分和动态优化统一起来的数学框架。
我自己的理解是:Voronoi图负责处理“空间分配”——把规划区域按就近原则切割成若干个子区,每个子区关联一个充电站或者配电网服务节点;围捕算法负责处理“动态协同”——当电动汽车的充电需求在时间和空间上随机出现时,调度策略要像猎手围捕猎物一样,把这些分散的需求引导到最合适的服务节点,同时避免某些站点负荷过载、另一些站点闲置。整个题目并不是四个独立概念的生硬拼接,而是一条从问题建模到算法求解的完整链路。
1.2 为什么这种方案能解决静态规划的痛点
传统配电网规划的路径,基本是“负荷预测 + 容量校核 + 选址定容”。在电动汽车保有量低的时候,这条路没问题,负荷曲线相对平稳,按峰值留裕量就能满足要求。但电动车的渗透率一旦上来,负荷的空间分布变化会非常剧烈:同一个城市片区,白天商业负荷高、居民区负荷低,到了晚上通勤车辆集中回家充电,居民区负荷瞬间飙升。如果沿用静态规划的思路,结果必然是部分站点长期过载、部分站点长期空置,既造成投资浪费,也埋下安全隐患。
围捕算法在这里的核心价值,是可以把“规划”变成“动态响应”。每个服务节点相当于一个猎人,充电需求相当于猎物,算法在每一个控制周期根据当前的负荷分布重新计算最优引导策略,Voronoi图的边界也会随站点状态变化自动调整。换句话说,规划不再是一锤子买卖,而是变成持续滚动的闭环优化。对于主动配电网来说,这种动态调整能力正是“主动”二字的意义所在。
提示:动手写代码之前,先界定你的场景偏静态还是偏动态。偏静态,围捕可以当作离线优化循环来写;偏动态,就要按实时控制循环来设计,代码结构完全不同。这个定位决定了后面很多参数的定义方式,也决定了仿真的计算量。
1.3 前置要求与阅读地图
复现这个项目不需要太深的理论基础,懂二维平面几何、会用MATLAB基本语法就够了。最关键的是理解两个概念:Voronoi图“就近分配”的几何内涵,以及围捕策略中“包围、收缩、捕获”三个环节的收敛逻辑。这两个概念我在下一节展开讲。
从实操角度看,这篇笔记的代码结构分成三层:第一层是数据结构和基础参数定义,第二层是Voronoi图生成与可视化,第三层是围捕策略的核心循环。建议你先从第二节弄懂原理,再对照第三节的代码逐步搭建,最后用第四节的实验设计去验证和调参。
2. Voronoi图与围捕算法的原理剖析
2.1 Voronoi图的数学本质:最近站点决定归属
Voronoi图本质上是把平面按照“距离最近”法则划分成若干个区域。给定一组种子点(也叫母点、站点)S = {s₁, s₂, …, sₙ},平面上任意一个点p,计算它到所有站点的欧氏距离,找到最小距离对应的那个站点,这个站点就是p的“管辖者”。所有被同一个站点管辖的点聚合在一起,就构成该站点的Voronoi区域。用数学语言写就是:
V(sᵢ) = {p ∈ R² | d(p,sᵢ) ≤ d(p,sⱼ),对于所有 j ≠ i}
这里 d 表示欧氏距离。这个定义看起来简单,但它有两条特别适合做空间决策的性质。
第一,区域之间无缝覆盖,不会漏掉任何位置。这意味着任意一个充电需求点,总能找到唯一一个距离最近的服务节点,不会出现“不知道该找谁负责”的空白地带。第二,区域边界是相邻两个站点连线的垂直平分线,正好体现“到两边距离相等”的中立原则。在充电站规划里,这个性质天然保证了公平性:两个相邻充电站服务范围的分界线,就是用户从两边过去代价相等的位置。
在MATLAB里生成Voronoi图有两条路。一条是直接用内置函数 voronoi(x,y) ,它负责画图,底层基于Delaunay三角剖分与对偶关系;另一条用 voronoin(coords) ,它返回顶点矩阵V和顶点索引cell数组C,方便做后续的面积计算、边界判断、自定义绘图。如果只是为了显示划分效果, voronoi 函数一行就够。但做围捕算法要不断动态更新站点位置、重新计算区域归属,用 voronoin 配合 polyshape 做区域裁剪会更顺手。
2.2 围捕策略的核心三环节:包围、收缩、捕获
围捕问题来源于多智能体协同控制领域,典型设定是一组“猎人”智能体需要协同包围一个或多个“猎物”目标。完整的围捕策略通常包含三个环节。
首先是包围。每个猎人根据当前的猎物位置和自己的相对方位,计算一个包围点,确保所有猎人站在猎物周围的不同角度上,形成大致均匀的圆形封锁线。这里的关键是角度分配,不能出现三四个猎人挤在同一侧、另一侧完全空着的情况。最简单的是均匀分配:如果有n个猎人,第k个猎人的期望方位角就是 2πk/n ,再配合猎人当前实际方位做加权平滑,就能自然地分散开。
其次是收缩。猎人一边保持角度分布,一边向猎物靠近,逐步缩小包围圈。收缩的速度不是越快越好,太快容易越过包围圈造成震荡,太慢又会导致围捕时间过长。这块一般用带速度上限的比例控制就能处理,不需要复杂的非线性控制。
最后是捕获。当至少一个猎人进入捕获半径,并且猎物无法脱离包围圈时,判定围捕成功。捕获半径是一个关键阈值,后面参数标定部分会单独讲。
这里面最核心的设计点是包围点的分配。均匀角度分割是最简单的策略,但一旦目标数量增多、猎人需要同时围捕多个猎物,均匀分配就不够用了。更进阶的做法是把位置分配建模成最优传输问题,或者用Voronoi图来做动态责任区划分——每个猎人负责自己Voronoi区域内的目标,区域边界随猎人位置变化而变化。这样“Voronoi图”和“围捕策略”就天然结合起来了,也正是这个题目真正值钱的地方。
2.3 围捕逻辑如何映射到电网调度场景
如果把围捕算法直接照搬到充电调度,很多初学者会卡在“物理意义”上:充电站是固定的,猎人怎么移动?
这里需要做一个合理的类比抽象。物理上的“猎人移动”,映射到电网里其实是“服务资源的控制重心移动”——充电站的物理位置不能动,但每个站的容量配置、调度权重、动态电价信号可以变。这些控制手段的效果,等效于改变了站点在规划空间中的“影响力范围”,等价于猎人向猎物靠近。
另一种更直观的映射是移动场景:应急充电车、移动储能装置就是实打实的移动猎人,它们可以直接向充电需求聚集区域移动。这种场景下围捕算法几乎不用做任何物理意义的转换,直接套用多智能体协同控制框架即可。在Matlab复现时,建议先在第二种场景里验证算法收敛性,再切换到第一种场景做参数映射,这样调试效率会高很多,因为代码层面的“站点坐标更新”逻辑不需要改,变的只是参数含义。
3. Matlab复现完整流程与关键代码
3.1 环境准备与数据结构设计
我用的是MATLAB R2023b,核心函数没有大的改动,所以R2019b以上的版本应该都能直接跑。不需要额外工具箱, voronoi 、 voronoin 、 polyshape 都是基础函数。如果后续要做更复杂的路径规划或强化学习调度,再考虑Mapping Toolbox或Optimization Toolbox。
数据结构上,建议用struct数组统一管理,比一堆散装变量清晰很多。基本字段如下:
- stations:n×2矩阵,存放n个服务节点的坐标
- targets:m×2矩阵,存放m个充电需求点坐标
- vel:当前节点速度向量,n×2
- radius:捕获半径标量,也就是判定需求被“成功服务”的距离阈值
- bounds:地图边界,例如 [0 10; 0 10] 表示10km×10km的规划区域
代码里多写一条注释说明每个字段的单位和含义,后面调参时能省很多事。比如 stations 的单位是千米, radius 的单位也是千米,两者必须一致,否则围捕判定会完全乱掉。
3.2 Voronoi图生成与区域边界处理
直接调用 voronoi 函数画图很简单:
stations = [3 7; 8 4; 5 8; 9 9; 2 3]; figure; voronoi(stations(:,1), stations(:,2)); hold on; plot(stations(:,1), stations(:,2), 'ro', 'MarkerSize', 8); axis equal; grid on; xlabel('x (km)'); ylabel('y (km)');这个函数能快速出图,但它是“画出来”的,不会直接给你每个区域的顶点坐标,后续做面积统计、归属判断都得自己再提取。所以实际项目里我更推荐用 voronoin 加 polyshape 的组合:
stations = [3 7; 8 4; 5 8; 9 9; 2 3]; [V, C] = voronoin(stations); figure; hold on; for i = 1:length(C) % 过滤掉包含 Inf 顶点的无限区域,只画有限区域 if all(C{i} ~= 1) poly = polyshape(V(C{i},1), V(C{i},2)); plot(poly, 'FaceColor', [0.85 0.85 0.85], 'EdgeColor', 'k'); end end plot(stations(:,1), stations(:,2), 'ro', 'MarkerSize', 8); axis equal; grid on;注意 voronoin 返回的顶点矩阵 V 中,通常第一行是 [Inf, Inf] 表示无穷远点, C{i} 里如果包含1,说明该区域外扩到了无穷远,直接绘制会产生很奇怪的形状,所以要先过滤。如果你只关心某个固定范围内的划分,强烈建议用 polyshape 的 intersect 函数把Voronoi区域和地图边界求交。这样每个区域都是闭合多边形,后续计算面积、质心、负载均衡指标都非常方便。
3.3 围捕策略的核心循环代码
围捕算法的实时控制循环,本质上是“判断归属 → 计算期望位置 → 更新站点状态”三步循环。下面给一段结构化示意代码,读者可以根据自己的场景修改:
% 参数初始化 stations = [3 7; 8 4; 5 8; 9 9; 2 3]; % 猎人/服务节点 targets = [4 6; 7 5; 6 8; 2 6; 9 7; 3 9]; % 目标/充电需求 R_cap = 0.8; % 捕获半径 v_max = 0.5; % 站点移动速度上限(等效控制速度) dt = 0.1; % 仿真步长 t_total = 20; % 总仿真时间 n = size(stations, 1); m = size(targets, 1); figure; hold on; axis equal; grid on; xlim([0 10]); ylim([0 10]); for t = 0:dt:t_total cla; % 清空画面重绘,便于观察动态 % 第1步:画出当前Voronoi划分 [V, C] = voronoin(stations); for i = 1:n if all(C{i} ~= 1) poly = polyshape(V(C{i},1), V(C{i},2)); plot(poly, 'FaceColor', [0.85 0.85 0.85], 'EdgeColor', 'k'); end end % 第2步:每个目标寻找最近的站点 assign = zeros(m, 1); for j = 1:m dist2 = sum((stations - targets(j,:)).^2, 2); [~, assign(j)] = min(dist2); end % 第3步:站点向分配给它的目标靠近(简化围捕策略) for i = 1:n belong = find(assign == i); if isempty(belong), continue; end goal = mean(targets(belong, :), 1); % 组团质心作为期望位置 delta = goal - stations(i, :); d = norm(delta); if d > 1e-6 && d > R_cap step = min(d, v_max * dt); stations(i, :) = stations(i, :) + delta / d * step; end end % 绘图 plot(stations(:,1), stations(:,2), 'ro', 'MarkerSize', 10, 'LineWidth', 1.5); plot(targets(:,1), targets(:,2), 'b^', 'MarkerSize', 8); pause(0.05); end这段代码的逻辑很直白:第1步画出当前Voronoi划分;第2步通过距离最近原则把每个目标分配给一个站点;第3步让站点向自己负责的目标群质心靠近,移动距离不超过速度上限。这已经是一个最简版的“围捕—服务”闭环,虽然没有做角度均匀包围,但足够演示核心流程。
真正要让围捕效果收敛好,还需要加“角度分散”逻辑。一种简单的做法是给每个站点赋予一个目标偏移方向,让它们不要全冲向同一个点。比如同一组内若有多个站点,就按目标数量均分角度扇区,把主方向和扇区方向的加权平均作为最终移动方向。这个升级建议在基础版本跑通之后再做,不要一开始就上复杂策略,否则出了bug很难定位。
4. 关键参数标定与实验设计
4.1 参数清单与选值逻辑
围捕算法涉及的核心参数其实不多,但有几个的取值严重决定结果。下表是我整理的一份常用参数,直接拿去做参考:
| 参数 | 含义 | 参考范围 | 设置依据 |
|---|---|---|---|
| n | 服务节点数量 | 5~20 | 对应实际充电站数量,越多覆盖越好但成本高 |
| m | 充电需求数量 | 10~100 | 对应车辆充电需求点数,越多计算量越大 |
| R_cap | 捕获半径 | 0.5~2 km | 对应服务距离阈值,太大失去调度意义 |
| v_max | 节点速度上限 | 0.2~1 km/步 | 对应控制响应快慢,太大会振荡 |
| dt | 仿真步长 | 0.05~0.2 | 实时控制周期,步长大误差大,步长小计算量大 |
| bounds | 规划区域 | 10km×10km | 视实际区域大小调整 |
R_cap 这个参数最值得解释。它如果设得非常大,站点几乎不需要移动就能“捕获”目标,整个系统退化成纯静态归属,围捕过程看不到;设得太小,站点要非常贴近目标才判成功,仿真时间会拖得很长。经验上取地图短边的5%~10%比较合适。10km的地图,取0.5~1km就是合理区间。
v_max 和 dt 这对参数需要配合着看。它们决定了每个控制周期内节点能移动的最大距离,也就是 v_max × dt 。如果一次移动距离远大于捕获半径,站点会来回穿越目标点,出现明显震荡;如果远小于捕获半径,系统收敛又太慢。我一般先定 dt = 0.1 ,再让 v_max × dt 约为 R_cap 的五分之一左右。
4.2 三个实验场景:静态、动态、加权
跑实验建议按三个递进场景来。
第一个场景是静态目标围捕。所有充电需求点位置事先给定且不再变化,观察站点能否在有限时间内走到合适位置并保持区域划分稳定。这个场景检验的是基础覆盖能力,也是调参最快的一步。只要静态场景能收敛,说明算法主循环没有结构性问题。
第二个场景是动态目标围捕。让充电需求点以一定速度随机移动,模拟真实道路上行驶的电动车。此时站点每时每刻都在根据目标位置重新计算归属、重新规划移动方向。这个场景考验的是算法的实时响应能力。如果站点速度上限跟不上目标移动速度,围捕永远不会成功——这个结论在电力调度场景下是有实际对应的:当需求变化太快而资源调度响应太慢时,系统就会失衡。
第三个场景建议做加权Voronoi围捕。不同充电站有不同的容量上限,用权重 w 表示,距离计算改成加权距离 d' = d / w 。权重大的站点吸引范围更大,权重小的站点责任区缩小。这一步是向真实电网约束靠拢的关键,因为现实里不可能所有充电站容量一样。
4.3 结果分析:三个评价指标
仿真跑完不能只看动画漂亮,要量化分析。我常用的评价指标有三个:
- 平均围捕时间:所有目标从开始到被成功服务(进入捕获半径)的平均步数,反映系统响应速度。
- 总服务路程:所有站点从起始位置到最终位置的总移动距离,反映调度成本。
- 负载均衡度:最终每个站点负责的目标数量标准差,方差越小越均衡。
代码里只需要在循环内多记录几行数据,比如每次成功服务后记录时间,每步累加移动距离,最后统计分配数量。这三个指标画成图,既能验证算法有效性,也能支撑后续与静态规划方案的对比——当你发现动态围捕策略能把负载均衡度提升多少时,方案的价值自然就凸显了。
另外,建议保存每次仿真的配置和结果参数,方便复现和横向对比。多组实验的参数与指标汇总成一张结果表,比口头描述直观得多。
4.4 实例跑分:10km区域里的8站35点
为了有一个具体感知,我用下面这组配置跑了一个实例:地图10km×10km,8个充电站初始位置随机生成,35个充电需求点按照三个区域聚簇分布(模拟居民区、商圈、高速服务区), R_cap = 0.8 , v_max = 0.5 , dt = 0.1 ,最大仿真时长50s。
实测的收敛过程大致是这样:前5s站点开始向聚簇区域移动,Voronoi区域边界持续变化;到第15s左右大部分站点进入稳定位置,此时负载均衡度(每个站点负责目标数量的标准差)从初始的5.2降到1.8左右;第30s左右所有目标均进入捕获半径,平均围捕时间约为18.5s。如果把同样的需求点交给不做动态响应的静态方案,标准差大概维持在4.5左右。这组数字直观说明了动态围捕策略对负荷均衡度的改善效果。
5. 常见问题与排查技巧实录
5.1 Voronoi生成阶段的两个经典坑
第一个坑是无限边界区域。前面代码里提到过Inf顶点,如果不做处理直接画 polyshape ,MATLAB会报错或者画出畸形多边形。解决方法是过滤 C{i} 中包含1的索引,或者用地图边界对每个区域做 intersect 裁剪。我实测下来,裁剪方案更实用,因为最终计算面积时边界外的无限区域没有意义。
第二个坑是种子点共线或者坐标重合。当多个站点排在一条直线上,Delaunay三角剖分会出现退化三角形, voronoin 输出异常。排查时先检查站点坐标是否有重复,再检查是否共线。如果站点数量较多且分布极不均匀,建议强制加入一个很小的随机扰动,能有效规避数值退化问题。
5.2 围捕过程不收敛或震荡
围捕不收敛最常见的原因是站点速度上限太低,或者目标移动速度太快。一个简单的判别式是:站点的最大步进 v_max × dt 必须大于目标单位时间位移的两倍。如果达不到,目标永远在站点追到它之前就溜走了。这类问题不需要改算法,调参数就能解决。
震荡则多半是增益太高或者步长太大。站点一步跨过目标位置,然后在目标两侧来回摆动。解决方法是降低 v_max ,或者让站点在距离目标小于阈值时减速而不是继续保持最大速度。前面建议的“ v_max × dt 取 R_cap 的五分之一”就是为了避免这种震荡。还有一种情况是多个站点同时追赶同一个目标,导致目标附近出现“抢位”现象,这种情况加角度分散逻辑就能解决。
5.3 性能与可视化调试
如果目标数量很多,比如100个,每步循环对每个目标计算到所有站点的距离,时间复杂度是O(n×m),在MATLAB里很容易成为瓶颈。优化方向是向量化:用矩阵减法把目标到所有站点的距离一次性算完,而不是用for循环逐个目标算。还可以把赋值判断用 min 函数一步完成,减少临时变量。
可视化调试的另一个技巧是把Voronoi边界透明度调高,目标点用三角形、服务节点用圆形,并且用不同颜色标注捕获状态(比如捕获成功的目标用绿色标记)。这样在高密度场景下也能快速看清站点是否遗漏了某些目标。如果仿真时间很长,还可以加一个进度条,避免干等。
6. 复现过程中的其他体会
做这个复现的过程中,我最深的体会是:交叉领域题目的难点往往不在算法本身,而在概念映射。Voronoi图和围捕算法各自都有成熟的数学基础,难的是把它们从“二维平面上的物理包围”翻译成“电网调度里的资源优化”。一旦你完成了这层抽象,后面无论是换约束、加权重、改成多目标优化,都只是在同一个框架上做修修补补。
最后再分享一个小技巧:仿真代码不要一开始就追求功能完整。先把“生成站点→画Voronoi图→目标归属→站点移动→判定捕获”这条最小链路跑通,每一步用绘图或命令行输出做可视化验证,确认无误后再逐步增加参数约束、动态场景、统计指标。这样调试过程会舒服很多,也不会被一堆参数之间的耦合关系绕晕。
如果后续要在实际项目里用这套思路,可以考虑扩展成Python版本,用scipy的Voronoi生成和numpy的向量化计算,跑大规模场景的速度会比MATLAB好不少。但作为原理验证和课程展示,MATLAB版本完全够用,而且绘图交互性更强,更适合把算法机理讲清楚。