基于NSGA-II的峰谷电价下电动汽车充电负荷多目标优化实践
这两年做电动汽车充电负荷优化和电网侧、用户侧的人聊了不少发现大家不约而同盯上了同一个问题电动车越来越多大家下班回家就充电高峰和高峰撞在一起变压器扛不住电费也难看。单纯靠电网扩容投资太大周期太长现实的做法是用价格信号把充电负荷“挪”到低谷去。但真做起来事情没那么简单——你想让用户少花钱电网想削峰填谷这两个目标不是完全一致的。我试过很多方法最后落地效果最稳、也最容易被同行认可的就是用多目标优化来做折中。这篇文章就把我之前在MATLAB里用NSGA-II求解峰谷电价下电动汽车充电负荷优化的完整实践从问题建模、算法设计到代码细节、坑点排查一次性讲清楚。这个项目解决的核心问题可以概括为一句话在分时电价峰谷电价的背景下通过NSGA-II多目标遗传算法优化电动汽车集群的充放电策略使得用户充电费用尽可能低、电网负荷波动尽可能小。适合正在做微电网/智能用电方向研究的硕博生、充电站运营方、以及想把“多目标优化”落到工程仿真里的工程师参考。全文不涉及硬件纯MATLAB仿真跑一遍下来你对NSGA-II的认知绝对上一个台阶。1. 问题拆解为什么无序充电是“灾难”峰谷电价引导到底引导什么做优化之前先把场景定义清楚。我们讨论的不是一辆车是几十上百辆车同时接入配电网的场景。每辆车都有到达时间、期望离场时间、电池容量、当前SOC、最大充电功率。如果没有任何管理车辆到达即充那么负荷曲线基本和用户的出行习惯强耦合——傍晚下班到家插上枪整个居民区的负荷曲线在19点到22点形成一个陡峭的晚高峰。这个峰和空调负荷、家用负荷叠加对配变来说非常不友好。峰谷电价的作用就在这里。电网公司把一天分成峰、平、谷三个时段谷时电价可能只有峰时的三分之一甚至更低。用户是理性的只要电价差够大就愿意把充电挪到半夜。于是“引导”这件事本质上变成了一种基于价格信号的负荷迁移。但这里面有个关键矛盾用户不是只有一个“省钱”诉求他还有出行需求——第二天早上电量必须够用电网也不是只关心“削峰”还关心负荷曲线整体平缓、变压器不过载、电能质量不恶化。这些都是多维度的而且存在冲突你想让用户把充电全放到凌晨2点省是省了但凌晨突然一大波充电负荷压上来电网又出现了新的“峰”。这就说明单目标优化根本回答不了这个问题。所以问题可以形式化为一个双目标优化问题目标一是用户总充电费用最小化目标二是电网负荷曲线方差最小化宏观上表现为峰谷差最小、负荷更平稳。在这两个目标之间不存在唯一最优解而是一组Pareto最优解。NSGA-II的价值就是把这组Pareto前沿高效地找出来供决策者根据不同偏好选择。1.1 分时电价机制与用户响应行为先看电价模型。以我常用的典型居民分时电价为例单位元/kWh时段划分大致是谷时23:00—次日7:000.35平时7:00—10:00、15:00—18:00、21:00—23:000.70峰时10:00—15:00、18:00—21:001.20这个价格结构意味着同样充满一块60kWh的电池谷时充电只要21元峰时充电却要72元差出三倍还多。对用户来说省钱的诱惑是实打实的。对于电网侧来说如果能把晚高峰的充电负荷挪到23点之后相当于在不扩容的前提下释放了配变容量效益也极其明显。但用户响应电价不是无条件的。一个核心约束是“出行需求不可牺牲”如果用户第二天早上8点要出门车辆必须在7点前充到一定SOC比如90%否则所谓的“省钱”就没有意义。所以充电负荷优化不是简单地把充电堆到谷时而是要把充电任务合理分配到允许的时间窗口内既要省钱又要保证出行。1.2 从“削峰填谷”到多目标冲突单看“削峰填谷”那问题很简单把所有能充的电都挪到谷时就行。但实际做下来你会发现如果所有车都集中在谷时一开始就大功率充电谷时段也会出现新的峰值变压器照样可能过载如果单纯以“负荷方差最小”为目标可能会让部分车辆充电功率被压得很低导致充满时间超出用户的离场时间反过来如果单纯以“用户费用最小”为目标求解器会想尽一切办法把电量填到谷时段的每一分钟产生“午夜高峰”。这个“午夜高峰”在数学上可能不违反约束但在工程上完全不可接受。所以我做建模的时候目标函数和约束条件必须同步考虑。目标函数可以写成f1 充电费用最小化即各时段充电功率乘以对应时段电价的累加f2 电网侧24小时负荷曲线方差最小化即把充电负荷叠加到基础负荷后整体负荷曲线的“平坦程度”最优。约束条件包括各时段充电功率不超过电网侧允许上限和充电桩最大功率每辆车的电池SOC不能超出[0.2, 0.95]的安全区间车辆离场时SOC必须达到用户设定值充电功率不能为负如果考虑V2G放电则允许负值本文暂不涉及。如此建模之后问题的多目标冲突本质就非常清晰了。2. 从单目标到多目标为什么偏偏是NSGA-II第一次做这个项目的时候我图省事直接用了加权求和法给充电费用和负荷方差各乘一个权重合成一个目标然后用遗传算法去优化。结果调了半天权重发现一个很尴尬的事情——权重为0.7:0.3和0.8:0.2解出来的策略差异极大而且Pareto前沿上凹或非凸时加权法根本找不到某些中间解。这就是单目标化方法的老毛病它一次只能给一个“折中答案”但你不是拍脑袋就能定权重。后来换成NSGA-II原理上就讲得通了。NSGA-II不是把多个目标压成一个而是在优化过程中同时维护一整组解用“非支配排序”来区分解的优劣——所谓支配是指一个解在所有目标上都优于另一个解。比如解A的充电费用比解B低负荷方差也比解B小那A就支配B。如果A在一个目标上更好、在另一个目标上更差那A和B互不支配它们都在Pareto前沿上。NSGA-II做的事情就是不断进化出一组尽量均匀分布在Pareto前沿上的非支配解。2.1 非支配排序与拥挤度距离的原理NSGA-II的核心机制说穿了就两块。第一块是快速非支配排序。每一代种群把所有个体按照支配关系划分成多个层级第一层是“没有被任何其他个体支配的解”第二层是“除去第一层后不被支配的解”以此类推。进化的时候层级越靠前的个体越优秀优先被保留到下一代。第二块是拥挤度距离。同一个非支配层里个体之间也得比个高下。拥挤度距离刻画的是某个解在目标空间里和相邻两个解的平均距离。距离越大说明这个解周围越“空旷”越值得保留因为它能维持解的多样性防止所有解挤在一小块区域里。这个机制非常巧妙——它不是额外加参数而是完全从当前种群的目标函数值分布中计算出来的。这两个机制一配合NSGA-II就同时解决了“收敛性”和“多样性”两个问题非支配排序保证种群整体向Pareto前沿收敛拥挤度距离保证前沿上的解尽量均匀铺开。2.2 选择、交叉、变异与精英保留有了评估机制剩下的就是经典的遗传操作。锦标赛选择每次随机抽两个个体优先挑非支配层级低的层级一样挑拥挤度距离大的。模拟二进制交叉SBX用于实数编码的决策变量交叉它让子代能继承父代的变量特征同时带一点随机扰动。多项式变异以较小的概率对某个决策变量做扰动避免种群早熟收敛。还有一个关键细节精英保留。每一代进化完成后把父代和子代合并成一个大种群先按非支配排序和拥挤度排序从前往后取前N个作为下一代。这样做的效果是最优的非支配解永远不可能在进化过程中丢失。这是NSGA-II相比第一代NSGA最核心的改进点之一。从我实测的结果来看种群规模100、进化代数200代配合SBX交叉和多项式变异在本文的充电优化问题上已经能稳定收敛到分布良好的Pareto前沿。如果解的分布有空洞可以适当加大交叉概率或种群规模。2.3 决策变量编码与大体流程这个优化问题的决策变量是每辆车在每个调度时段内的充电功率。如果一天有24个时段车辆数为N那么决策变量就是一个N×24的矩阵展平后就是一个长度为24N的实数向量。编码方式直接决定了种群初始化、交叉和变异怎么实现。大体流程就是输入基础数据电价曲线、车辆参数、基础负荷、调度时段初始化种群随机生成Nind个个体每个个体是一个24N维的实数向量代表整个车辆集群一天的充电计划计算每个个体的两个目标函数值费用、负荷方差非支配排序计算拥挤度锦标赛选择、SBX交叉、多项式变异生成子代种群父代子代合并精英保留生成新一代种群判断是否达到最大进化代数是则输出Pareto前沿否则回到步骤3对Pareto前沿解进行后处理分析典型方案和指标。这个流程在MATLAB里实现起来非常顺因为矩阵运算天然支持种群级别的并行计算不需要像Python一样写循环去遍历每个个体。3. MATLAB实现细节从数学模型到可跑代码有了算法层面的设计接下来说点实操的。很多论文只给公式不给你说人话——我这里直接按我能跑通的代码逻辑把每个模块拆开讲。3.1 输入参数初始化的具体设定先给出一份我用的参数表这份表格可以直接作为复现的基础参数名称数值说明调度时段数T24以1小时为一个时段电动汽车数量N30参与调度的电动汽车总数电池容量C_b/kWh60常见纯电车型电池容量最大充电功率P_max/kW7家用交流慢充桩功率充电效率η0.95交流转直流的综合效率初始SOC范围0.2~0.5用户到达时剩余电量目标SOC0.9用户离场时期望达到的电量SOC安全范围[0.2, 0.95]保护电池防止过充过放峰时电价/(元/kWh)1.2010~15时、18~21时平时电价/(元/kWh)0.707~10时、15~18时、21~23时谷时电价/(元/kWh)0.3523~次日7时种群规模Nind100NSGA-II种群个体数最大进化代数Gen200迭代终止条件交叉概率Pc0.9SBX交叉概率变异概率Pm0.1多项式变异概率基础负荷我用的是一条典型的夏季居民日负荷曲线峰值大约在350kW谷值约150kW。这里用列向量base_load表示长度24。3.2 种群初始化与可行性处理种群里每个个体是N×24的功率矩阵但如果在MATLAB里直接rand一个N行24列的矩阵会出现大量不可行解比如SOC超出边界、离场电量不够。所以在初始化阶段就要控制变量的意义。我的做法是先按车辆的到达时段和离场时段确定充电窗口。比如第i辆车18:00到达次日7:00离场那么有效充电窗口就是18~23时和0~7时共13个小时其他时段的功率强制为0。在充电窗口内随机生成功率值时还要保证一个前提总充电量至少满足目标SOC与初始SOC的差值。charging_window zeros(N, T); for i 1:N charging_window(i, arrive(i)1:d株part(i)1) 1; % 简化写法 end P rand(N, T) .* P_max .* charging_window; % 初始充电计划但这样生成的解SOC约束很可能不满足。我的初始化函数里写了一个修复机制算一下每辆车在整个窗口内能充到的SOC如果达不到目标值就在窗口内功率比较低的时段补上差额直到满足。这样能保证初始种群全部是可运行解而不是靠后面罚函数去慢慢“拉”回来。这个细节非常重要。如果你不处理初始可行性NSGA-II在前几十代里几乎都在无效搜索收敛速度肉眼可见地慢。3.3 目标函数计算费用与负荷方差目标函数写成一个函数文件输入是决策变量矩阵PN行T列输出是费用和负荷方差两个标量。费用计算很直接% soc初始化矩阵 soc zeros(N, T1); soc(:, 1) soc_initial; for t 1:T soc(:, t1) soc(:, t) P(:, t) * dt / C_b; % dt1小时 end elec_price repmat(price, N, 1); % price是1x24电价行向量 total_cost sum(sum(P .* elec_price * dt)) / eta;注意充电效率不能忽略。电池侧吸收的能量才是转化为SOC的能量而电网侧消耗的电能要除以效率所以费用计算里要把效率放进去。负荷方差的计算total_load base_load sum(P, 1); % 各时段总负荷 基础负荷 充电负荷 load_var var(total_load, 1); % 用总体方差目标函数返回[total_cost, load_var]两个都是越小越好。3.4 约束处理的三种方式这个项目里约束比较多我的处理方式是按约束类型分开处理充电功率上下限约束在初始化/交叉/变异时就直接裁剪到[0, P_max]区间属于变量边界约束SOC上下限约束在目标函数里检查如果SOC超过[0.2, 0.95]返回一个巨大惩罚值比如1e10离场SOC约束在初始化时修复在进化过程中用惩罚项兜底。为什么SOC下限和离场约束不直接在变量生成时修复因为交叉和变异是随机算子产生越界子代是必然的如果每个子代都修复计算量会很大而且会破坏遗传算子的探索能力。用惩罚项则可以“温和地”淘汰不可行解同时允许算法在进化过程中短暂地触碰不可行域这对搜索往往是有利的。但惩罚系数不能拍脑袋定。如果罚得不够狠最终解可能大量落在不可行域罚得太狠则和直接丢弃不可行解没区别。我试过自适应罚函数效果不错但对初学者不友好。简单粗暴的办法是凡是有约束违反的个体直接把两个目标函数值都加上一个10^8量级的大数这么做虽然粗糙但对NSGA-II这种基于支配关系的算法实际效果足够好——因为它不需要精确的惩罚梯度只需要“这个解很差”的信息。3.5 NSGA-II核心代码结构与关键函数我习惯把NSGA-II拆成几个函数文件init_pop.m种群初始化返回Nind个个体evaluate.m计算种群所有个体的两个目标函数值non_dominated_sort.m快速非支配排序返回每个个体所在的层级crowding_distance.m计算每个个体的拥挤度距离selection.m锦标赛选择sbx_crossover.m模拟二进制交叉poly_mutation.m多项式变异nsga2_main.m主循环。主循环的核心代码结构for gen 1:Gen % 锦标赛选择产生父代 parent_idx selection(pop, obj, rank, crowd_dist, Nind); % 交叉变异生成子代 offspring sbx_crossover(pop(parent_idx,:), Pc); offspring poly_mutation(offspring, Pm); % 边界裁剪保证功率在[0, P_max] offspring min(max(offspring, 0), P_max); % 合并父代和子代 combined_pop [pop; offspring]; [obj_comb, ~] evaluate(combined_pop); % 非支配排序 拥挤度计算 [rank_comb, ~] non_dominated_sort(obj_comb); crowd_comb crowding_distance(obj_comb, rank_comb); % 精英保留取前Nind个个体 [~, idx_sorted] sortrows([rank_comb, crowd_comb], [1, -2]); pop combined_pop(idx_sorted(1:Nind), :); end关于SBX交叉和多项式变异的MATLAB实现网上代码很多但我建议自己敲一遍。这个过程中最容易犯错的是MATLAB下标从1开始而许多伪代码从0开始以及交叉分布指数、变异分布指数对解分布的影响。我实测下来交叉分布指数eta_c取20、变异分布指数eta_m取20在这个充电优化问题上表现比较均衡。分布指数太大子代和父代过于相似收敛慢太小搜索太随机Pareto前沿不光滑。这里有一个方便的小技巧MATLAB里不需要自己实现完整的NSGA-II可以直接用MATLAB自带的gamultiobj函数——它就是基于NSGA-II的。如果你只想快速验证模型是否正确直接调用options optimoptions(gamultiobj, ... PopulationSize, 100, ... MaxGenerations, 200, ... Display, iter); [x, fval] gamultiobj((x) my_objective(x), nvars, ... [], [], [], [], lb, ub, (x) my_constraints(x), options);但要注意gamultiobj对自定义约束函数的写法有要求——约束函数需要返回[c, ceq]且c≤0表示约束满足。用它做验证很方便不过做研究建议还是自己实现一遍NSGA-II尤其是需要改算子、加约束时自研代码的灵活度要高得多。4. 仿真结果与分析Pareto前沿长什么样最优策略怎么选跑完200代进化MATLAB画出来的Pareto前沿大致是一条从左上到右下倾斜的曲线。横轴是充电费用纵轴是负荷方差。举一个典型结果的例子方案充电费用/元负荷方差/kW²特点方案A最小费用约620约8500用户最省钱但负荷波动大方案B折中约720约6000费用和负荷波动相对均衡方案C最小波动约890约4800电网侧最优用户费用升高较多这三个方案都满足车辆的SOC离场约束区别在于充电功率在时间轴上的分配方式完全不同。这个结果实际上揭示了一个规律Pareto前沿曲率最大的那段就是“性价比”最高的折中区域。在这个区域里稍微增加一点充电费用就能换来负荷方差的大幅下降但过了某个点之后再增加费用负荷方差下降就变得非常缓慢。做决策时大部分人应该选方案B而不是方案C因为方案C多花将近170元换来的负荷方差改善却只有20%左右。这就是Pareto前沿对决策的支持意义——它不是直接告诉你“最优解”是哪个而是告诉你“选择空间长什么样”。4.1 与无序充电、单目标优化的对比光有Pareto前沿还不够要验证方法有效得有对照组。我算了三种场景作为对比无序充电车辆到达即充直到充满或离场。费用约950元负荷方差约12000 kW²。这是最差的结果用户在晚高峰充电电费最高配变压力最大。单目标最小费用优化用GA跑费用约600元但负荷方差飙到11000 kW²以上。原因是算法疯狂把充电塞到谷时造成了明显的新负荷峰。双目标NSGA-II方案B费用约720元负荷方差约6000 kW²。和无序充电相比费用降低了24%负荷方差降低50%和单目标最小费用比费用只增加了20%但负荷方差改善超过45%。这个对比非常直观地说明了多目标优化的价值与其在一个目标上做到极致、牺牲另一个目标不如找一个两边都过得去的折中解整体收益反而最大。4.2 充电功率分配的热力图分析把最优个体对应的N×24功率矩阵画成热力图能非常直观地看出算法学到的“充电习惯”23:00之前几乎没有车在充电。绝大多数车在19:00~22:00是“观望”状态SOC保持在初始值附近23:00开始车辆陆续进入充电状态而且不是所有车同时满功率充电。算法会把车辆分成多个批次有的车0~2点充有的车2~4点充有的车4~6点充6:00~7:00之间几乎没有充电任务因为大多数车的SOC在此时已经到达目标值。这个分批充电的现象特别有意思。它不是我们人为设定的规则而是NSGA-II在面对“负荷方差最小化”这个目标时自发演化出来的策略——把充电任务均匀铺开比所有车挤在同一时段充电负荷方差要小得多。如果只用遗传算法去优化单一目标你很难看到这种智能的调度行为。4.3 参数敏感性种群规模、迭代次数、交叉概率怎么调做实验的时候我特意扫了几组参数把结论记录下来种群规模从50增加到200Pareto前沿的均匀性明显变好最优点折中区域解的稳定性也更强。50个个体时两次独立跑的结果差异很大200个个体时基本稳定。迭代次数150代以后Pareto前沿不再有显著变化。但注意这只是对本文这个规模的算例而言。如果你增加车辆数或细化时段间隔收敛代数会相应增加。交叉概率0.9比0.7好因为本文的目标函数虽然有惩罚项但整体地形相对平滑较大交叉概率能保持种群活力变异概率0.05~0.1之间差异不大但超过0.2会明显变差因为变异太频繁会破坏已经形成的优质模式。这些参数总结成一句话先保证种群规模和迭代次数足够再微调交叉变异概率不要一上来就追求“高级技巧”。5. 踩过的坑与排查技巧MATLAB调试真实记录这个项目前前后后跑了一周多中间踩了不少坑。挑几个有代表性的写出来帮你节省几天的调试时间。5.1 “解全在不可行域”的排查第一次跑通主循环后我发现Pareto前沿上的点全部落在错误的区域——充电费用异常低但SOC约束全部违反。查了半天问题是出在目标函数里约束判断的顺序上。因为费用目标函数的值域在600~1000左右而我用1e10作惩罚值按理说不可行解的目标函数应该非常大支配关系会立刻把它们淘汰掉。但问题在于我的非支配排序函数收到的是两个目标值组成的矩阵而MATLAB排序时对双重惩罚值处理得不够稳导致某些带惩罚的个体反而因为“数值溢出”干扰了排序结果。解决方法是在目标函数返回前先做一次数值检查如果约束违反直接把两个目标值同时置为Inf。这样非支配排序里Inf的个体天然就是最差的层级不会被误判为正面的解。if any(soc 0.95 | soc 0.2) || min(soc_final target_soc) 0 f1 Inf; f2 Inf; return; end5.2 MATLAB向量化与for循环的性能差距我一开始图省事目标函数里对每辆车用for循环计算SOC跑一次种群评估要好几秒。200代进化下来一个下午就没了。后来重构成矩阵运算所有车辆的SOC递推用矩阵表达一次循环都不需要。soc soc_initial cumsum(P, 2) * dt / C_b;这个改写让计算时间缩减了将近10倍。如果你的实验场景扩大到了几百辆车用cumsum这种向量化写法是必须的否则性能根本扛不住。5.3 时间窗定义混乱导致调度失败这是我犯过最蠢的错误把到达时段和离场时段的下标定义反了。MATLAB的数组下标从1开始所以第1个小时对应0:00~1:00第2个小时对应1:00~2:00。如果车在18:00到达对应下标18次日7:00离场对应下一个数组周期的下标31而不是7。这个“跨天下标”问题第一次处理时很容易犯错。我建议用一个统一的“绝对时段”坐标把一天视为0~24的连续时间轴车辆到达时刻和离场时刻都用小时数表示然后统一映射到1~24的下标。如果离场时刻在第二天直接用arrive24来计算。写代码前先在草稿纸上把时间轴画一遍把每辆车的到达、离场、充电窗口标清楚能避免大量低级错误。5.4 常见问题速查表问题现象可能原因解决方法Pareto前沿只有一两个点种群规模太小或变异概率太低增大种群规模到150适当提高变异概率前沿分布不均匀拥挤度距离计算有问题检查排序函数是否按目标值排序确认拥挤度公式的分母不为零目标函数计算极慢使用了大量for循环矩阵化成cumsum、sum等向量化操作所有解都不可行初始化没有做可行性修复在初始化函数里补充SOC达标修复两次运行结果差异大进化代数不足或种群规模小增大最大代数和种群规模必要时设置随机数种子固定复现交叉变异后大量越界边界裁剪没做在遗传操作后加min(max(...))裁剪到[0, P_max]gamultiobj结果异常约束函数返回值格式不对确保c 0表示满足且ceq为空矩阵6. 从仿真到落地的几点延伸思考文章写到这里核心内容基本说全了。最后再分享一点我自己的体会。这个项目做完之后最大的收获不是学会了NSGA-II这个算法本身而是理解了“多目标优化”的工程意义现实世界里几乎所有决策都是多目标的但目标之间常常互相打架真正成熟的决策不是找到一个“完美解”而是把冲突摊在桌面上让决策者看清取舍。NSGA-II给我的不是“答案”而是一张“Pareto前沿地图”。再分享一个后续可以扩展的方向当前模型里假设每辆车的充电功率是连续可调的但真实充电桩尤其是交流慢充往往是“要么充、要么不充”的开关状态对应整数规划。把功率变量离散化后NSGA-II的交叉变异算子需要改成针对二进制编码的处理方式问题会更复杂但也更贴近实际。另一个扩展方向是考虑V2G也就是电动汽车反向放电这时候决策变量范围变成负功率目标函数里充电费用也会变成“充放电净收益”Pareto前沿的形状会有新变化。从复现的角度说按本文的参数表和步骤MATLAB跑一遍应该很顺利。建议你先用30辆车、24个时段这个小规模场景跑通全流程再逐步扩大车辆数或时段粒度体会一下不同场景下Pareto前沿的变化规律。调试过程中如果遇到概率分布、变异强度之类的问题记住一个原则NSGA-II的容错性比单目标算法好得多只要保证初始种群可解、惩罚项不过分剧烈、精英保留正确结果通常不会太差。