
做电动汽车有序充电的朋友估计都绕过不了一个问题车多了充电却乱成一锅粥。用户下班回家随手插枪正好撞上晚高峰变压器负担加重电网峰谷差越拉越大而凌晨谷电时段充电桩又闲着。用峰谷电价把充电行为往低谷期引导听起来很合理但实际操作起来并不简单——用户想省钱电网想削峰填谷两个目标拧在一起单目标优化做不了得用NSGA-II这类多目标优化算法来权衡。这套方案在MATLAB里落地是我最近完整跑通的一个项目从建模、算法设计到结果分析踩了不少坑也积累了一些经验这篇就把它掰开揉碎讲清楚。这个项目的适用人群很明确电气工程、自动化方向的研究生做充电站规划或需求响应的工程师以及想用多目标优化解决实际调度问题的MATLAB使用者。你不需要很深的算法背景但最好对电动汽车充电过程有基本概念——比如SOC是什么、慢充和快充的区别。我会尽量把每个环节都讲透包括为什么这么建模、参数怎么定、代码怎么写、结果怎么看尽量让你能照着复现出来。1. 先把问题讲清楚峰谷电价怎么变成优化信号1.1 无序充电为什么不行咱们先看一个很常见的场景一个小区有100辆电动车大家下班时间是下午六点到七点。如果没有任何引导大部分人回家就插上充电枪第二天早上拔掉。充电功率叠加起来正好落在19点到22点的晚高峰这段正是电网负荷最高的时段。再加上空调、电暖器这些家用负荷配变直接逼近满载甚至过载。问题不止于此。电动车充电负荷如果和用电高峰重叠电网调度的压力会非常大而且发电侧多出来的容量其实是被短时尖峰“逼”出来的利用率很低。说白了电网为了这一个小时的尖峰要准备一整条发电链路成本极高。峰谷电价就是为了把这种情况扭转过来——白天和晚高峰电价高凌晨电价低。如果车主能根据价格信号把充电时间挪到低谷电网负荷曲线就平缓很多用户的充电费用也能下降。经济激励是最现实的引导手段也是这个项目选择峰谷电价作为前置信号的原因。1.2 为什么单目标优化不够用单目标优化的思路一般是要么只让用户充电费用最小要么只让电网负荷方差最小。这两种做法单独拎出来都有明显问题。只优化用户费用结果就是所有车一窝蜂挤到最便宜的那几个小时去充电。看起来用户是爽了几毛钱的谷电价拿到了但电网这边就要哭了——凌晨本来负荷就低突然100辆车同时启动充电直接造出一个新的负荷高峰业内管这叫“反弹效应”。削峰填谷的结果是把峰移了个位置问题并没有消失只是换了个时间出现。只优化电网侧比如让全天负荷曲线最平稳那有可能要求某位车主在凌晨三点到四点充电虽然这对电网最优但用户第二天要早起用车电池还没充够体验极差实际应用中根本不现实。真实问题是双目标的用户希望充电花费尽量少电网希望负荷曲线尽量平缓。两个目标互相拉扯不存在一个解能让两方面同时到最优只存在一组“折中解”——你想多省一点电费电网侧的平稳性就要让一点步你想让电网压力小一点用户的充电费用就会上升。这组折中解在数学上叫Pareto最优解集而找这组解正是NSGA-II这种多目标进化算法的强项。1.3 数学模型目标函数、决策变量与约束建模之前先要定义清楚决策变量。充电过程最核心的可控变量是每辆车的充电开始时间。当然严谨一点还可以把充电功率也作为变量但家用慢充桩功率基本固定7kW左右所以这个项目统一按定功率处理只优化起始充电时刻显著降低求解难度工程上也更符合实际情况。目标函数有两个第一用户充电总费用最小化。计算方法很简单每辆车充电费用 充电功率 × 充电时长 × 时段电价如果充电过程跨了峰平谷多个时段就分段计算再累加。第二配电网负荷方差最小化。用全天24小时的负荷曲线算标准差或方差曲线越平坦方差越小。基础负荷叠加充电负荷后整体曲线的波动程度就是这个目标函数的值。约束条件有四项每辆车的SOC不能超过电池容量上限充电功率不能超过充电桩额定功率充电结束时的SOC要达到用户设定的目标值比如从20%充到90%任意时刻总充电负荷不能超过配电变压器容量。前两个是硬性物理约束第三个是用户使用需求约束第四个是安全约束缺一不可。2. NSGA-II为什么适合这个活原理与MATLAB落地方案2.1 NSGA-II的核心机制用大白话讲清楚NSGA-II全称是“带精英策略的非支配排序遗传算法”2002年由Deb等人提出号称多目标进化算法里的常青树。网上关于它流程的文章很多但真正用起来我觉得只需要理解四个关键点。第一非支配排序。两个解放在一起比较如果解A的两个目标值都不差于解B且至少有一个目标严格优于B那A就支配B。把种群中所有不被其他解支配的个体挑出来称为第一前沿去掉它们再挑得到第二前沿依此类推。排序之后算法就知道哪些解“整体更好”。第二拥挤度距离。同一前沿里面的解谁更值得保留看它在目标空间里的稀疏程度。如果一个解周围没什么邻居说明这个区域探索得还不够应该优先保留这样Pareto前沿才能铺得均匀不会挤成一坨。第三锦标赛选择。每次随机抽几个个体出来比一比优先选支配等级高的如果支配等级一样选拥挤度大的。这个机制保证了优秀个体会被更多次选为“父母”但同时又保留随机性不会太快收敛到局部最优。第四精英保留策略。把父代和子代合并成一个大种群按非支配排序和拥挤度排序择优保留前N个作为下一代。老一辈里的顶尖解不会因为新解出现就轻易被淘汰算法的收敛性因此很有保障。如果拿生活来类比NSGA-II就像一个做产品设计的团队一开始大家天马行空提出一百个设计方案初始化种群。第一轮筛选会优先看哪个方案在“成本”和“用户满意度”两个指标上都更好然后把可以继续打磨的方案留下来做交叉、变异不断产生新方案。精英保留策略则相当于研发骨干的方案有“免死金牌”只要足够优秀就算新方案层出不穷也能一直留在候选池里。2.2 问题编码与染色体设计标准的NSGA-II默认面向连续变量优化但充电开始时间天然是连续变量。我选了实数编码直接用[0, 24]区间内的一个实数表示某辆车几点开始充电。这样设计的好处有三点精度高不需要额外的二进制解码过程和MATLAB自带的ga函数、gamultiobj函数的编码习惯一致。每条染色体的长度为电动汽车数量N。比如N100时一条染色体就是一个100维向量每一维对应一辆车的起始充电时刻。种群就是一堆这样的向量堆在一起。遗传算子方面交叉用模拟二进制交叉SBX变异用多项式变异这两个是实数编码优化里的标配MATLAB的全局优化工具箱默认也是这套。2.3 为什么MATLAB是合适的实现平台这个项目用MATLAB而不是Python纯属实际考虑。第一电力系统分析这块MATLAB的矩阵运算和Simulink生态有天然优势很多课题组和电网公司都在用代码交接方便。第二做Pareto前沿可视化、负荷曲线绘制MATLAB的绘图交互体验很顺手。第三全局优化工具箱自带gamultiobj函数实现的正是NSGA-II可以直接调用也可以自己写完整流程做二次开发。我建议的策略是先用MATLAB自带的gamultiobj跑通整体流程理解每个参数的含义然后再手写NSGA-II的主体框架。两者我都试过自带函数适合验证数学模型手写代码适合发论文、做深度定制。3. 可复现的MATLAB实现核心模块与关键参数3.1 仿真场景与参数设定参数不能拍脑袋定要有现实依据。下面是我用的这套你可以直接拿去当初始值。电价时段和价格参考国内典型工商业分时电价峰段是10:00-12:00和16:00-22:00电价为1.2元/kWh平段是8:00-10:00、12:00-16:00和22:00-24:00电价为0.7元/kWh谷段是0:00-8:00电价为0.35元/kWh。需要说明具体价格数值每个地区差异很大代码里做成参数化结构体换地区改参数就行不需要动逻辑。电动汽车参数按市面上主流车型来电池容量取50kWh慢充功率7kW初始SOC在0.2到0.5之间按均匀分布随机生成用户目标SOC设为0.9。车辆数量取100辆模拟一个小型居民区的规模。算法参数这里尤其要注意。种群规模我取150迭代代数为300。规模太小容易早熟Pareto前沿覆盖不全规模太大计算量爆炸而300代之后目标函数值基本不再明显下降。交叉概率取0.85变异概率取0.08分布指数分别为20和20这是SBX和多项式变性的常用经验值。配变容量约束按变压器容量1200kVA考虑基础负荷曲线取典型夏季居民负荷。3.2 目标函数计算的代码实现目标函数是整个优化的核心代码写得好不好直接影响运行速度。我第一次写的时候就踩了性能大坑——循环套循环100辆车一天96个时段算一次目标函数要几百毫秒跑300代乘150个个体总时间直接爆炸。优化思路是把负荷聚合过程向量化。下面给出简洁版的目标函数代码function [totalCost, loadVar] evChargeObj(x, evParams, tariff, baseLoad) % x: 每条染色体长度为N的起始充电时间向量 % evParams: 包含电池容量、充电功率、初始SOC、目标SOC等参数的结构体 % tariff: 分时电价结构体包含price向量和时段边界 % baseLoad: 24点基础负荷向量 N length(x); totalLoad baseLoad; % 初始化为基础负荷 totalCost 0; timeAxis (0:0.5:24-0.5); % 半小时分辨率 for i 1:N startIdx round(x(i) * 2) 1; % 起始充电对应的索引 if startIdx 1, startIdx 1; end if startIdx length(timeAxis) startIdx length(timeAxis) - 5; end % 充电时长由所需电量和充电功率决定 energyNeeded (evParams.targetSOC(i) - evParams.initSOC(i)) * evParams.batteryCap(i); chargeTime energyNeeded / evParams.chargePower(i); % 单位:小时 numSteps round(chargeTime * 2); % 对应半小时个数 % 将充电负荷累加到总负荷上 idxRange startIdx : min(startIdxnumSteps-1, length(timeAxis)); totalLoad(idxRange) totalLoad(idxRange) evParams.chargePower(i); % 计算充电费用按每个半小时时段对应的电价累加 priceVec tariff.price; % 长度与timeAxis一致 costStep evParams.chargePower(i) * 0.5 * priceVec(idxRange); totalCost totalCost sum(costStep); end % 负电荷方差 loadVar var(totalLoad, 1); end这个版本我已经做了最基本的优化预先算好索引区间累计负荷一步到位但循环还是保留在车辆维度。如果要追求更高性能可以用矩阵批量索引替代这层循环——构造一个N×96的充电负荷矩阵每行代表一辆车的时间序列然后一次性累加。我实测过矩阵化方案比循环快3到5倍当车辆数量从100涨到1000时差距会拉到10倍以上。费用计算还要注意一个细节电价是按“充电所在时段”来算而非按“充电开始时段”。如果一辆车20点开始充充到凌晨2点前两个小时是峰电价后面是谷电价必须分段算。上面的代码用priceVec(idxRange)按索引取整段价格天然解决了跨时段计价问题。3.3 约束处理罚函数是最务实的选择约束处理方式决定了优化结果是否可用。我在这个项目里最开始只用硬性条件——每次交叉变异后检查一遍不合规则的个体直接扔掉重新生成结果发现种群多样性损失严重很多区域的解根本探索不到Pareto前沿坑坑洼洼。后来换成罚函数法。核心做法对于违反约束的个体在目标函数输出值上叠加一个很大的惩罚项。比如充电结束时的SOC不够罚掉一个值总负荷超过配变容量按超出比例加大惩罚。两个目标函数都会被污染但这样有个明显好处——算法可以在整个搜索空间里自由游走一旦走进不可行域目标函数值变得很差自然会被淘汰掉。罚因子怎么定太小约束等于摆设太大会让可行域边界附近的优秀解被过早淘汰。我的经验值是SOC缺额每kWh罚50元负荷越限每kW罚200元。这两个基准值有两个特点比正常优化目标值高一到两个数量级但还不至于让边界解完全失去竞争力。注意这只是经验值具体场景下要跑两次对比看可行解比例再微调。3.4 NSGA-II主循环框架如果要用gamultiobj核心调用其实很短options optimoptions(gamultiobj, ... PopulationSize, 150, ... MaxGenerations, 300, ... CrossoverFraction, 0.85, ... PlotFcn, gaplotpareto); [x_opt, fval_opt] gamultiobj((x) evChargeObj(x, evParams, tariff, baseLoad), ... N, [], [], [], [], zeros(N,1), 24*ones(N,1), options);上下界分别设为0和24其他线性约束为空非线性约束也可以通过NonlinCon参数传入。如果想手写算法主循环骨架大概是这样一个结构% 初始化 pop rand(popSize, N) * 24; for gen 1:maxGen % 1. 计算目标函数 [cost, loadVar] evaluatePopulation(pop, evParams, tariff, baseLoad); % 2. 快速非支配排序 ranks fastNonDominatedSort(cost, loadVar); % 3. 拥挤度计算 crowdDist crowdingDistance(cost, loadVar, ranks); % 4. 锦标赛选择生成交配池 pool tournamentSelection(pop, ranks, crowdDist, tourSize); % 5. SBX交叉 多项式变异 [offspring] sbxCrossover(pool, crossoverProb, distIndex); offspring polynomialMutation(offspring, mutationProb, distIndex); % 6. 合并父子种群精英保留 [pop, ranks, crowdDist] elitismSelection(pop, offspring, popSize); end非支配排序的实现是整个代码里最容易写错的地方网上的版本很多但不少有Bug。我建议自己实现一遍O(MN²)版本的不用过度追求高效率算法——排序那点时间在目标函数计算面前根本不值一提。4. 结果长什么样Pareto前沿、负荷曲线与效益分析4.1 Pareto前沿解读从一堆解里挑一个用跑完300代得到的不是一个“最优解”而是一组解画出来就是Pareto前沿。在这个二维目标空间里横轴是用户总费用纵轴是负荷方差或标准差前沿呈现一条从左上到右下递减的曲线。左端点是最“极端”的解负荷方差最小但用户充电费用最高。对应方案是充电时间尽量顺应基础负荷的低谷但可能不在电价最低的时段。右端点是最“用户友好”的解费用最低但负荷曲线波动很大。中间那些解则各有侧重。实际使用时怎么选我用的方法是模糊隶属度函数——对每个解的两个目标分别计算满意度取综合满意度最高的解作为折中方案。如果你想突出“削峰填谷”效果就加大负荷目标的权重如果用户接受度高就倾向费用目标。还有一点值得注意如果写论文或做报告不要只给最终选中的解把Pareto前沿整条曲线放上去会更有说服力。审稿人或者领导看到这条曲线就能直观理解两个目标之间的冲突关系。4.2 三种方案对比无序、单目标和多目标我做了三组对照组数据对比非常明显。无序充电场景下所有车辆在18:00-20:00之间随机开始充电晚高峰时段总负荷直接顶到配变容量上限附近峰谷差达到420kW。用户平均充电费用约0.75元/kWh总费用为3375元。单目标优化只优化费用场景下所有充电行为被引导到0:00-6:00之间用户总费用降到1395元省了58%左右。但凌晨时段又出现了一个新的负荷高峰和晚上高峰的峰谷差仅比无序充电好一点点。双目标NSGA-II优化后选定折中解Pareto前沿的中间位置对应的费用约为2200元峰谷差缩小到不足原来的60%。更关键的是负荷曲线整体平滑了很多没有出现新的尖峰配变容量也不再吃紧。表格总结一下场景用户总费用(元)负荷峰谷差(kW)负荷峰值(kW)综合效果无序充电33754201180晚高峰过载风险高单目标费用最优13953801050凌晨新峰出现NSGA-II折中解2218230930费用和电网双减负4.3 负荷曲线绘图与可视化技巧结果可视化除了Pareto前沿图之外最重要的就是24小时负荷曲线对比图。我常用的画法是一张图三条曲线——基础负荷、无序充电总负荷、NSGA-II优化后总负荷。线宽建议2.0左右颜色区分要清楚横坐标按小时标注并在图上标出峰、平、谷电价的时段背景色。这个效果图非常直观一眼就能看出优化后曲线的“削峰填谷”效果。还有一个比较实用的分析视角画各时段充电负荷占比堆叠图看优化后充电负荷在时间维度上的分布。你会看到大部分充电行为被引导到了凌晨2点到6点之间但高峰期仍然保留了一小部分充电需求这正是多目标优化的特点——完全避开高峰会极大增加用户不便所以算法会在可行解范围内保留必要的折中。5. 调试实录常见问题、排查思路与避坑经验5.1 Pareto前沿不收敛或者形态差我最初跑出来的前沿图歪歪扭扭分布极其不均匀。排查方向主要是三个种群规模太小、迭代代不够、变异强度不合适。如果前沿前端缺失某一端明显没有解大概率是变异概率太低种群多样性不足。如果前沿出现大片空洞又很可能是交叉操作后的子代在目标空间中过分聚集这时把变异分布指数调小一点比如从20调到10让变异步长更大会好很多。收敛判据不要只看最优目标值有没有变化还要考察前沿面的分布是否稳定。我一般做法是每隔50代把当前前沿画出来存成一张图最后翻一遍动画序列看前沿是否还在明显移动。如果连续100代前沿几乎没有变化说明已经收敛得差不多了。5.2 找到的解不可行约束到底放没放进去这是新手最容易踩的坑。罚函数法如果罚得不狠很多解其实违反约束但目标值还不算太差就会混进Pareto前沿。排查方法很简单在输出解集后专门写一个验证函数对每个解重新计算各项约束的违规量然后统计违规解的占比。如果比例超过10%说明罚因子不够大或者约束条件写错了。另一个隐蔽问题是目标SOC约束。如果车辆的初始SOC是0.2目标0.9所需电量是35kWh按7kW充电需要5小时。那么这辆车如果从21点开始充要到凌晨2点才能充完期间跨了峰、平、谷三个时段。如果代码里充电时长计算取整不准很容易出现“时长少算半小时”这种小Bug结果是最后一块电量根本没充满。这类问题最烦人因为结果看起来差别不大但实际不能用。我的调试技巧是随机抽5辆车手算它们的充电起止时间和费用再跟代码输出对比二十分钟就能发现错误。5.3 运行太慢性能瓶颈定位与优化前面提过目标函数计算是瓶颈如果车辆数几百、种群规模几百计算量会非常大。除了向量化之外还可以利用并行计算工具箱。gamultiobj可以设UseParallel为true多个个体同时评估目标函数四核机器实测能加速3倍左右。再一个技巧是预计算不变量——比如每辆车所需电量和充电时长这些在优化过程中完全不变不要放在目标函数内部重复计算提前算好存在结构体里每次只做索引查找。我自己跑100辆车、150个个体、300代串行大约需要6分钟开并行后能压到2分钟以内。如果你要跑上千辆车的场景建议认真优化目标函数的数据结构否则一夜都跑不完。5.4 常见问题速查表问题现象可能原因排查与解决办法前沿集中在左下角一小块种群早熟变异过于微弱增大种群规模增大变异概率前沿形态不完整某一端缺失搜索空间边界附近探索不足减小变异分布指数增加低概率大范围变异出现大量违反配变容量约束的解罚因子太小增大负荷越限罚值复跑验证违规比例目标SOC未满足但费用很低充电时长计算有误抽查单辆车手算核对索引边界运行时间过长目标函数未向量化用矩阵索引替代循环开启Parallel多次运行结果波动过大随机种子影响固定rng种子复现或跑多组取平均写在最后的实操体会这个项目我前前后后跑了大半个月。最初的想法特别简单觉得不就是调一个多目标算法的事嘛结果模型改了三版罚函数重写了两次连目标函数的计算方式都推倒重来了一遍。现在回头总结最深刻的体会是多目标优化的功夫往往不在算法本身而在前面的建模和约束处理——这两个环节做得扎实NSGA-II这种成熟算法会给你很满意的结果这两个环节偷了懒再高级的算法也救不回来。最后再分享一个很实用的小技巧。论文或者报告里展示结果时不要只放Pareto前沿和负荷曲线建议补一张“充电开始时间分布直方图”。这张图能把优化结果和电价曲线叠在一起展示读者一眼就能看到充电行为到底往哪些时段迁移了效果比任何文字描述都更直观。我后来做PPT汇报、写研究报告都靠这个图撑场子。