前阵子帮某园区做综合能源优化调度,第一版程序只把运行成本设为单目标,优化结果出来成本确实漂亮,风电光伏却大量弃用,碳排放考核也没过关。后来换成基于非支配排序遗传算法NSGA-II的多目标优化调度,把成本、碳排放、弃风弃光率放到同一个框架里跑,一次迭代就得到一整条权衡方案,才真正拿到能落地的调度计划。这篇就写清楚NSGA-II在综合能源优化调度里到底怎么用、Matlab代码怎么组织,以及那些文档里不太会写、只有真正跑起来才会遇到的坑。
1. 综合能源调度为什么需要NSGA-II:从一个弃风难题说起
1.1 综合能源系统的“多目标调度”到底是什么
先说场景。一个典型的综合能源系统,往往包含燃气轮机、风力发电、光伏、电储能,有些还带热泵和电锅炉,电负荷之外还有热负荷。日常运行中最核心的问题叫“日前优化调度”:根据第二天的负荷预测、新能源出力预测、分时电价,决定各台设备未来24小时每个时段发多少电、储能不能充放、要不要从上级电网买电。
这个问题的麻烦在于它是一个不折不扣的多目标优化问题。运行成本要低,碳排放要少,新能源要尽量全消纳,避免弃风弃光。可这三个目标天然存在矛盾:想让成本最低,可能倾向多买谷电、多烧燃气,结果排放上去了;想让新能源全消纳,电网消纳不了的部分就要机组压低出力甚至启停调峰,成本又上去了。你不可能用一个单一数值完美描述这个取舍过程。
所以综合能源调度这类问题,本质上需要的不是“给我一个最优解”,而是“给我一批在多个目标之间做了各种取舍的可调度方案,然后人再去拍板”。这一点,恰好是NSGA-II这类多目标进化算法的看家本领。
1.2 加权求和的单目标做法,问题出在哪
很多初做调度的人,第一反应是把成本、碳排放这些目标用一个加权和公式合并,变成单目标优化。比如f = w1成本 + w2碳排放,然后用粒子群或常规遗传算法去搜。这种做法不是不行,但有几个很现实的毛病。
第一个毛病是权重怎么定。W1取0.7、w2取0.3,得到的解和w1取0.5、w2取0.5得到的解完全不同,但到底哪个权重对应决策者的真实偏好,没人说得清。第二个毛病是量纲问题。成本的单位是万元,碳排放的单位是吨,两个数可能差着数量级,直接加权的话小量纲的目标几乎被淹没,归一化处理又会让结果对归一化方式很敏感。第三个毛病更隐蔽——如果Pareto前沿是非凸的,线性加权法无论如何配权重,都找不到中间那一部分解。
我在实际调参里对这个体会特别深。有一版程序把成本权重调到0.82、排放权重调到0.18,看起来“兼顾了两者”,跑出来的结果却始终在低成本高排放和低排放高成本两个极端之间跳,中间过渡段完全空白。后来换了NSGA-II,才第一次看到完整的折中前沿。
1.3 NSGA-II能同时给出什么答案
NSGA-II的全称是Non-dominated Sorting Genetic Algorithm II,非支配排序遗传算法二代。它不把多个目标合并成一个数值,而是直接同时优化整个目标向量,通过“非支配”的概念把种群分成多个层次的Pareto前沿。第一层就是当前种群里的最优解集合,这些解两两之间互有胜负,有的成本低排放高,有的排放低成本高,但没有一个在三个目标上全面碾压另一个。
进化结束后,你得到的不再是一个点,而是一系列点组成的Pareto前沿。决策者可以从前沿上挑:想环保一点,选排放低的那端;想经济一点,选成本低的那端;想两头兼顾,选前沿膝盖位置的折中解。这个过程对工程场景特别友好,因为在项目早期你往往不确定“绿色”和“省钱”到底怎么平衡,多给一批方案远比拍脑袋定一个权重靠谱。
2. NSGA-II三大核心机制拆解:非支配排序、拥挤度、精英保留
2.1 支配与非支配排序:把种群一层层分成前沿
NSGA-II的基石是“支配”概念。个体A支配个体B,条件是A在所有目标上都“不差于”B,至少在一个目标上“严格优于”B。比如目标是成本和碳排放,A的成本比B低,碳排放又不高于B,那A就能支配B。
非支配排序做的事情,就是把种群里的所有个体分层。第一层是所有“不被任何其他个体支配”的个体,也就是当前最好的Pareto前沿;去掉这一层之后,再在剩下的个体里找不被剩下的支配的,作为第二层,以此类推。层数越靠前,个体质量越高。这个过程的Matlab实现我给大家贴一段标准的写法:
function [fronts, rank] = fastNonDominatedSort(objVals) [N, M] = size(objVals); % N个个体,M个目标 S = cell(N, 1); % S{i}:被个体i支配的个体集合 n = zeros(N, 1); % n(i):支配个体i的个体数量 fronts = {}; for i = 1:N for j = 1:N if i == j, continue; end if dominates(objVals(i,:), objVals(j,:)) S{i} = [S{i}, j]; elseif dominates(objVals(j,:), objVals(i,:)) n(i) = n(i) + 1; end end end front = find(n == 0); % 第一层:没人支配它们 k = 1; while ~isempty(front) fronts{k} = front; Q = []; for i = front for j = S{i} n(j) = n(j) - 1; % 删除当前层后,计数器减掉 if n(j) == 0 Q = [Q, j]; % 下一层候选 end end end k = k + 1; front = Q; end rank = zeros(N, 1); for k = 1:length(fronts) rank(fronts{k}) = k; end end function d = dominates(a, b) d = all(a <= b) && any(a < b); end这里的两层循环复杂度是O(MN^2),种群100到200时完全跑得动,不用刻意优化。真正在工程里要留意的是目标个数:目标从两个涨到四个五个,非支配比例会迅速增加,第一层里几乎全是解,排序的区分度反而下降。这也是为什么综合能源调度一般建议先聚焦成本、碳排放、弃风弃光率这三个核心目标,不要贪多。
2.2 拥挤度距离:同一层里也要求“均匀分布”
非支配排序只回答了“谁比谁更优先”,没回答“同一层的解之间怎么互相比较”。如果只按分层选个体,种群会倾向集中在Pareto前沿的某个局部,多样性变差。NSGA-II的处理方式是给同一前沿内的每个个体算一个拥挤度距离。
拥挤度距离的计算思路很直观:对每个目标,把同一前沿内的个体按目标值排序,边界个体的距离设为无穷大,保证一定保留;中间个体的距离等于它前后两个个体在该目标上归一化后的差值之和。所有目标加起来就是这个个体的拥挤度。距离越大,说明它周围的解越稀疏,越值得保留。
function CD = crowdingDistance(objVals, front) numObj = size(objVals, 2); CD = zeros(size(front)); for m = 1:numObj [~, idx] = sort(objVals(front, m)); sortedFront = front(idx); CD(idx(1)) = inf; CD(idx(end)) = inf; fmin = objVals(sortedFront(1), m); fmax = objVals(sortedFront(end), m); if fmax - fmin < 1e-12 continue; end for k = 2:length(front)-1 CD(idx(k)) = CD(idx(k)) + ... (objVals(sortedFront(k+1), m) - objVals(sortedFront(k-1), m)) / (fmax - fmin); end end end我在调试时发现,如果某个目标在种群进化初期就几乎不变化,fmax-fmin会特别小,归一化后拥挤度全被这个僵死目标淹没。遇到这种情况先别急着改算法,回去检查编码和约束是否把该目标卡死了,比如碳排放只由购电决定,而购电被固定,那排放这个目标就等于常数,自然没法提供信息。
2.3 精英保留与锦标赛选择:进化不会一代不如一代
早期多目标遗传算法有个痛点:最优个体在进化过程中可能被交叉变异破坏,导致解的质量反复震荡。NSGA-II的改进是精英保留策略——它把父代种群和子代种群合并成一个2N规模的大种群,先按非支配层排序,层数小的优先;同一层内拥挤度大的优先。最后从合并种群中选出前N个做下一代父代。
这个“父代+子代混合再排序”的设计,保证了任何一代里的最优个体只要没有在支配关系上被超越,就一定能保留到下一代,解的质量只升不降。配合锦标赛选择,整个进化过程就很稳。锦标赛选择的逻辑是每次随机抽K个个体,K一般取2,从里面挑rank小、拥挤度大的那个当父本:
function parent = tournamentSelection(population, rank, CD, K) sel = randi(size(population, 1), K, 1); best = sel(1); for k = 2:K if rank(sel(k)) < rank(best) || ... (rank(sel(k)) == rank(best) && CD(sel(k)) > CD(best)) best = sel(k); end end parent = population(best, :); end2.4 实数编码下的SBX交叉与多项式变异
综合能源调度里的决策变量是各时段出力,都是连续量,我一般用实数编码而不是二进制编码。实数编码有两个好处:一是解空间连续,相邻出力值之间没有编码跳变;二是配合模拟二进制交叉(SBX)能够保留父代的多变量相关性,收敛速度比二进制交叉快。
SBX交叉的核心是生成一个分布因子beta,beta小于1时子代靠近父代,大于1时远离父代,由分布指数etaC控制。etaC取15到20,子代有较大概率落在两个父本附近,同时可能产生离群解,兼顾开发与探索。多项式变异则用分布指数etaM控制扰动幅度,对当前基因值做一个小范围偏移,变异概率取1/n,n是决策变量个数。这两个算子的Matlab实现不复杂:
function child = sbxCrossover(p1, p2, etaC, lb, ub) child = zeros(size(p1)); for i = 1:length(p1) if rand() < 0.5 u = rand(); if u <= 0.5 beta = (2*u)^(1/(etaC+1)); else beta = (1/(2*(1-u)))^(1/(etaC+1)); end child(i) = 0.5*((1-beta)*p1(i) + (1+beta)*p2(i)); else child(i) = p1(i); end end child = min(max(child, lb), ub); end function m = polynomialMutation(x, etaM, lb, ub) m = x; for i = 1:length(x) if rand() < 1/length(x) u = rand(); if u < 0.5 delta = (2*u)^(1/(etaM+1)) - 1; else delta = 1 - (2*(1-u))^(1/(etaM+1)); end m(i) = x(i) + delta * (ub(i) - lb(i)); end end m = min(max(m, lb), ub); end3. 综合能源调度问题的数学建模与Matlab表达
3.1 决策变量设计:以24小时调度周期为例
先把问题建模清楚。我习惯用1小时步长,调度周期24小时,这样既贴近市场出清粒度,又不至于让决策变量膨胀到难以收敛。假设系统里有1台燃气轮机、1个风电场、1个光伏电站、1组电储能,并允许从上级电网购电。
决策变量向量x可以这样设计:
- 燃气轮机出力:P_gt,24维
- 储能充放电功率:P_bat,24维,正为放电、负为充电
- 风电实际出力:P_w,24维
- 光伏实际出力:P_pv,24维
- 电网购电功率:P_buy,24维
总维数24*5=120维。把购电也作为决策变量,会让功率平衡等式多了一条检查路径,看似增加了求解难度,其实好处是能直接约束购电上限和峰谷分时电价的响应行为,程序逻辑也更清晰。变量上下界根据设备参数设定,比如风机实际出力下界0,上界是预测可用功率,光伏同理。
3.2 三个目标函数:运行成本、碳排放、弃风弃光率
第一个目标是运行成本,包含燃气轮机的燃料成本、购电费用、储能的运行维护成本,再扣掉如果有余电上网的售电收益:
F1 = sum(C_fuel(P_gt) + price_buyP_buy + c_batabs(P_bat)) - sum(price_sell*P_sell)
燃料成本可以用二次函数C_fuel = aP_gt^2 + bP_gt + c,也可以用线性分段近似。二次形的表达更精细,但会给优化带来非线性的梯度,在NSGA-II里其实无所谓的,因为遗传算法不依赖梯度。所以能用二次就用二次,别为了迁就“线性求解器”硬做线性化。
第二个目标是碳排放,把购电视为上级电网的间接排放,燃气轮机视为直接排放:
F2 = sum(e_gridP_buy + e_gasP_gt)
第三个目标是弃风弃光率。风机光伏的实际出力一旦低于预测可用值,就产生了弃用。为了避免分母为零,加上一个极小量:
F3 = sum(P_w_max - P_w + P_pv_max - P_pv) / sum(P_w_max + P_pv_max + 1e-6)
三个目标的量纲、数量级都不同,不需要人工归一化,交给非支配排序去比大小即可,这反而是多目标方法比加权法省心的地方。
3.3 约束条件与罚函数处理
综合能源调度约束多且杂,主要包括:
- 功率平衡:燃气轮机出力+风电+光伏+购电+储能放电 = 电负荷+储能充电
- 燃气轮机上下限及爬坡约束
- 风电、光伏实际出力不超过预测值
- 储能SOC动态方程:S(t+1) = S(t) + eta_chP_chdt - P_dis*dt/eta_dis
- 储能SOC上下限和功率上下限
- 购电功率上限
约束处理我推荐两种方式。第一种是罚函数法,把每个约束的违反量平方后乘以一个大系数加进目标里。这个方法编码简单,但系数不好调,尤其当功率平衡的违反量是兆瓦级而碳排放是几十吨的时候,不把违反量归一化,罚函数系数怎么试都别扭。第二种是约束支配法,也是我现在更喜欢的方式:定义个体总约束违反度g,在非支配比较里,约束违反度小的个体优先。具体说,两个个体比较时,如果都可行就按目标支配;如果只有一方可行,可行方赢;如果双方都不可行,约束违反度小的赢。
约束支配法不需要调罚函数系数,也没有罚函数可能造成的“牺牲标准解”的副作用。代码上只需要在适应度函数返回目标向量的同时返回约束违反度,然后在比较函数里加一个约束违反度的判断即可。
3.4 适应度函数的代码骨架
综合起来,适应度函数写成下面这样的结构:
function [f, g] = fitnessFunc(x, params) n = params.nHours; idx_gt = 1:n; idx_bat = n+1:2*n; idx_w = 2*n+1:3*n; idx_pv = 3*n+1:4*n; idx_buy = 4*n+1:5*n; P_gt = x(idx_gt); P_bat = x(idx_bat); % 正放负充 P_w = x(idx_w); P_pv = x(idx_pv); P_buy = x(idx_buy); f1 = sum(params.a * P_gt.^2 + params.b * P_gt + params.c) ... + sum(params.price_buy .* P_buy) ... + sum(params.c_bat .* abs(P_bat)); f2 = sum(params.e_gas .* P_gt + params.e_grid .* P_buy); f3 = sum(params.P_w_max - P_w + params.P_pv_max - P_pv) ... / (sum(params.P_w_max + params.P_pv_max) + 1e-6); % 约束违反度,这里统一聚合 g = constraintViolation(P_gt, P_bat, P_w, P_pv, P_buy, params); f = [f1 f2 f3]; endconstraintViolation函数内部会把每个约束超出限度的部分取max(0, violation),然后求平方和。SOC方程则从t=1到24递推,检查每个时刻的SOC是否越界、首末SOC是否满足日循环要求。写这部分的时候,我建议把每个约束的违反量单独记录成一个向量,方便调试时定位是哪个约束在拖后腿。
4. Matlab主程序实现:从初始化到Pareto前沿出炉
4.1 总体流程与数据准备
整个程序的流程按下面这个顺序走:
- 初始化参数结构体params:负荷曲线、风光预测、分时电价、设备参数
- 随机生成初始种群pop,规模N
- 评估每个个体的目标向量和约束违反度
- 对当前种群做非支配排序,计算拥挤度
- 用锦标赛选择选出父本,执行SBX交叉和多项式变异,生成子代
- 合并父代和子代,重新评估、排序,精英选择出新父代
- 循环直到最大进化代数
参数准备用Matlab结构体非常方便:
params.nHours = 24; params.load = loadData; % 1x24 电负荷预测 params.P_w_max = windForecast; % 1x24 风电预测可用功率 params.P_pv_max = pvForecast; % 1x24 光伏预测可用功率 params.price_buy = priceBuy; % 1x24 分时购电价 params.price_sell = priceSell; % 1x24 上网电价,可设为0 params.P_gt_max = 80; % 燃气轮机上限 MW params.P_gt_min = 8; params.ramp = 20; % 爬坡速率上限 MW/h params.bat_cap = 40; % 储能容量 MWh params.SOC_min = 0.2; params.SOC_max = 0.9; params.eta_ch = 0.95; params.eta_dis = 0.95; params.P_buy_max = 60;注意Matlab里向量运算要处理好行/列方向。我有一个不成熟的教训:load是1行24列,P_gt是24行1列,两个直接相加就会维度报错。建议全部统一成1x24的行向量,或者在进入适应度函数时用(:)'强制转一下。
4.2 非支配排序、精英选择与主循环
主循环的核心代码结构如下,这里把第2章的排序函数和拥挤度函数串起来了:
popSize = 100; numVar = 120; nGen = 300; lb = zeros(1, numVar); ub = ones(1, numVar); % 具体边界解码到各段后进行缩放下界和上界 % 这里简化,实际应分设备段设置上下界,例如燃气轮机[P_gt_min, P_gt_max] pop = repmat(lb, popSize, 1) + rand(popSize, numVar) .* repmat(ub-lb, popSize, 1); for gen = 1:nGen [objVals, gVals] = evaluatePop(pop, params); front = fastNonDominatedSort(objVals); CD = zeros(popSize, 1); for k = 1:length(front) CD(front{k}) = crowdingDistance(objVals, front{k}); end offspring = []; while size(offspring, 1) < popSize p1 = tournamentSelection(pop, rank, CD, 2); p2 = tournamentSelection(pop, rank, CD, 2); c = sbxCrossover(p1, p2, 15, lb, ub); c = polynomialMutation(c, 20, lb, ub); offspring = [offspring; c]; end % 合并父代与子代 popAll = [pop; offspring]; [objAll, gAll] = evaluatePop(popAll, params); frontAll = fastNonDominatedSort(objAll); CDAll = computeAllCD(objAll, frontAll); % 精英选择:按层数和拥挤度排序取前popSize combinedIdx = []; orderKeys = []; for k = 1:length(frontAll) orderKeys = [orderKeys; ...]; end精英选择的排序细节可以用一个小小的技巧:先按rank升序,再按拥挤度降序,然后截取前popSize。Matlab里用sortrows可以一步完成,注意加了负号取反即可实现拥挤度降序。
4.3 结果输出与Pareto前沿绘图
进化结束后,把第0层前沿的目标值画出来,我一贯用散点图:
front1 = front{1}; plot(objVals(front1, 1), objVals(front1, 2), 'o'); xlabel('运行成本 / 万元'); ylabel('碳排放 / t');如果需要三维展示三个目标,可以用scatter3。从图上能直观看到三个目标两两之间的权衡关系。选定某个折中解后,把它对应的决策变量解码,画成24小时出力图:燃气轮机、风电、光伏、储能、购电各自一个子图,和负荷曲线叠在一起看,就能确认调度方案是否合理。
4.4 性能加速的小技巧
很多读者跑起来会觉得慢,原因往往是种群个体数太大又嵌套了循环。有几个立竿见影的加速手段。一是向量化评估:把所有个体的一次性丢进评估函数,用矩阵运算代替逐个for循环。比如计算功率平衡违反量,直接对决策矩阵做矩阵减法。二是减少重复评估:合并选择后,父代个体已经被评估过了,不必再算一遍,但为了代码清晰我一般选择重复评估,损失一些时间换可读性。三是先跑小规模验证:把24小时缩成8小时、种群缩到40,把程序逻辑调通后再放大,能省很多调试时间。
5. 参数调优与实测排查心得
5.1 种群数、迭代数、交叉变异率的搭配
先给一组我实测下来比较稳的参数:
| 参数 | 推荐范围 | 说明 |
|---|---|---|
| 种群规模 | 100-200 | 变量120维时,100个个体已经能用,追求质量取200 |
| 进化代数 | 200-500 | 前100代收敛快,后100代主要在扩展前沿覆盖 |
| 锦标赛K | 2 | K越大选择压力越大,但容易早熟 |
| SBX分布指数etaC | 15-20 | 值越大子代越接近父本 |
| 多项式变异分布指数etaM | 20-40 | 值越大扰动幅度越小 |
| 变异概率 | 1/numVar | 约0.008,固定值即可 |
一个常见误区是一味加大种群和代数。我试过种群400、代数1000,跑了整整一晚,得到的前沿和种群200、代数300的结果差别不大,计算量却翻了四五倍。原因是NSGA-II在前期很快就能覆盖前沿的大致范围,后面主要是局部打磨和保持多样性,对工程项目来说没有必要追求完美连续的前沿。
5.2 罚函数系数与约束违反度怎么调
如果选择罚函数法,最让人头疼的就是罚系数。我的经验是先看约束违反度g的量级,把g归一化到1以内再乘罚系数。比如功率平衡最大偏差是20MW,那就先除以20;SOC越界0.05,就除以0.05。归一再设penalty = 100,效果远好于不归一埋头试系数。如果用约束支配法,则完全不用调这个参数,我强烈建议新写的代码直接用约束支配。
另外要注意约束不能“全硬”。以储能SOC为例,首末SOC要求相等,这个条件在实际运行里往往过于严格。我处理时会允许末端SOC和起始SOC之间存在一个很小的容差,比如0.5%电量,否则整个解空间会被大量几乎不可行解占据,收敛极慢。
5.3 结果怎么看:前沿形状与调度合理性
拿到Pareto前沿后,别急着挑解,先检查三点。
第一,看前沿是否完整覆盖两端。前沿应该从“高成本低排放”延伸到“低成本高排放”,如果某一端缺失,说明目标函数或约束限制了解空间,比如购电上限设得太死,导致低成本方案覆盖不到。
第二,看拐点。前沿变化率明显放缓的那个区域通常是膝盖区,这里“用很小成本增加换很大排放下降”,是做项目时最常选的操作区间。
第三,验证调度曲线。选一个折中解,画出24小时出力图,重点检查储能是否谷充峰放、风电光伏是否“能消纳就消纳”、燃气轮机在峰谷时段的出力是否合理。如果储能在电价谷段不充电、峰段不放电,或者风机明明有出力预测却在白天大量弃风,大概率是约束写错了,先不要怀疑算法。
5.4 与单目标加权法的实测对比
我拿同一个园区算例分别跑过加权单目标和NSGA-II。加权法我试了5组权重,得到5个点;NSGA-II跑一次,得到100个互不支配的非分解,其中有一半以上是加权法那5组权重无法覆盖的中间态解。差距最大的时候,某组权重下的“最优解”其实被NSGA-II前沿上的另一个解完全支配:成本相同,排放却低了8%。
这里面的道理其实很简单:加权法本质是把多目标压缩成一个方向去搜索,而NSGA-II是多方向同时探索。对综合能源调度这种目标互相打架的问题,多方向探索带来的信息量是碾压级的。
最后再分享一点个人经验:做这类项目时别把目标函数堆太多,成本、碳排放、新能源消纳三个目标已经是多数园区决策者能消化的上限。四个目标以上,Pareto前沿会高维化,可视化、决策、连带的主程序调试都会变得非常麻烦。先把三目标版本做扎实,把约束处理好、结果讲明白,再谈扩展更多目标的事,这是我在几个项目里反复验证过的路线。