这两年做电力系统优化调度方向的仿真,感受最深的一件事是:很多同学拿到一份“梯级水电和火电联合调度”题目,第一反应是想办法把一堆目标压成一个单目标,然后扔给求解器。这种做法不能说错,但在实际科研和工程预研里,发电量、耗水量、污染物排放这些指标往往是互相打架的,强行加权平均只会掩盖真实的调度权衡。我后来改用NSGA-Ⅲ多目标进化算法做梯级水电和火电机组联合调度,在Matlab里搭了一套完整代码,才算是真正把“跑出结果”变成了“能讲清楚为什么是这个结果”。这篇就把我踩过的坑、模型怎么建、NSGA-Ⅲ为什么选它、代码按什么结构写,以及结果怎么分析,一次性讲清楚。
1. 为什么梯级水电和火电机组要放在一起做多目标调度
1.1 水电火电的互补性:自然来水与可控调节之间的博弈
梯级水电的特点是“有水才有电”,来水的随机性、季节性和上下游水力联系,让每一级电站的出力不是独立的。火电机组则相反,出力可控、响应相对稳定,但代价是燃料成本和污染物排放。单独调度水电,汛期可能弃水,枯期可能出力不足;单独调度火电,碳排放和煤耗又压不下来。把两者放在联合调度的框架里,本质上是让水电的“随机能量”和火电的“调节能量”互相补偿。
在模型里,梯级水电之间还有一重约束:上一级电站的出库流量会直接决定下一级电站的入库流量。这个延迟和水头影响,是很多初写代码的人最容易忽略的。
1.2 单目标优化为什么不够用
如果只把目标设为“总发电量最大”,计算机会把所有能发的电都发出来,完全不考虑耗水和排放。如果只把目标设为“耗水量最小”,梯级电站会把水尽量留到高水头时放,火电就得大量补上缺口,成本和排放随之飙升。这些目标之间的冲突不是靠调整权重就能很好解决的,因为权重本身怎么定就是个难题。
多目标调度的意义在于,它一次性算出一组帕累托最优解:在这组解里,任何单独一个目标的改进,都必然以至少另一个目标的劣化为代价。调度人员可以根据当天的来水预报、电网负荷需求、环保约束,在帕累托前沿上选择偏发电、偏节水还是偏减排的方案。
1.3 为什么多目标进化算法成了主流选择
调度模型里到处都是非线性约束:水头-库容曲线、机组出力限制区、火电爬坡速率。传统数学规划方法处理这种问题需要很多简化,而进化算法只需要把目标函数和约束写清楚,剩下的交给种群迭代。NSGA系列是应用最广的一类,到NSGA-Ⅲ这一代,重点解决了高维目标下种群多样性丢失的问题。梯级水库加火电,目标通常三到四个,用NSGA-Ⅲ正好能发挥它的优势。
2. 先建模:目标函数和约束条件怎么落到数学上
2.1 三个目标:发电量最大、耗水量最小、污染排放最小
我在自己的代码里做了三个目标,这也是这个领域最常用的组合。第一个目标是梯级水电总发电量最大,同时火电也要满足负荷缺口,所以干脆直接设为“系统总发电收益最大”,折算成总发电量来处理更直观。第二个目标是梯级水电总耗水量最小,最小化各站的发电流量总和。第三个目标是火电污染物排放量最小,这里按火电出力的二次函数近似排放量。
写成数学形式大概是这样:
- F1 = -sum(P_hydro(t) + P_thermal(t)),最大化发电量就等价于最小化负值。
- F2 = sum(Q_discharge(i, t)),各梯级电站的发电流量之和。
- F3 = sum(a(i) * P_thermal(i, t)^2 + b(i) * P_thermal(i, t) + c(i)),火电排放。
这三个目标里,F1和F2通常是矛盾的:多发水电一方面增加发电量,另一方面也会消耗更多水量。F1和F3在负荷固定的情况下也是矛盾的:水电发得少,火电就要补出力,排放自然上升。
2.2 约束条件清单:库容、出力、水量平衡一个都不能漏
约束条件决定了模型是否可信。我在代码里处理了这么几类:
- 水量平衡约束:水库库容变化 = 入库流量 - 发电流量 - 弃水流量。这个约束是逐时段、逐电站写的。
- 库容上下限约束:每个梯级电站都有防洪限制和死库容限制,不能超出。
- 发电流量约束:受机组最大过流能力和最小技术出力的双重限制。
- 电站出力约束:水电站出力由水头、发电流量和机组效率共同决定,火电出力有上下限。
- 火电爬坡约束:火电从一个时段到下一个时段的出力变化不能超过最大爬坡速率。
- 系统负荷平衡约束:所有电站出力之和需要等于给定负荷需求。
这些约束里,最麻烦的是水电站出力与水头、流量之间的非线性关系。工程上通常用查表或者拟合函数来表现,我为了加快Matlab运行速度,事先把所有电量计算做成向量查表,避免在循环里反复插值。
2.3 决策变量编码与模型-算法接口
NSGA-Ⅲ本身不关心你的变量是连续还是整数,它只负责对编码后的个体做交叉和变异。我采用实数编码,每个个体代表一个调度方案。调度时段的个数乘上电站个数,再加上火电的时段出力,就得到了每个个体的维数。
举例来说,24个时段、3座梯级水电站、1座火电厂,个体就是一条长度为 24*(3+1) = 96 的实数向量。前 72 个分量是各水电站的发电流量,后 24 个分量是火电在各时段的出力。水电发电流量经过水量平衡递推,能得到库容轨迹,再经过约束修正,得到完整调度方案。
接口设计上,我把目标函数封装成 function [F, violation] = evaluate(individual, data),返回目标值和约束违反量。算法只调用这个接口,内部怎么算水量平衡、怎么查水头-出力关系,完全不影响主循环。这样调试起来方便,换算例也不需要动算法核心代码。
3. NSGA-Ⅲ凭什么比NSGA-Ⅱ更适合高维目标调度
3.1 从非支配排序到基于参考点的选择机制
NSGA-Ⅱ和NSGA-Ⅲ的第一步都是非支配排序:把种群按帕累托支配关系分层,第一层是最优的非支配解集,第二层是被第一层支配的解里再次非支配的集合,以此类推。这一步把“谁比谁好”变成“你在哪一层”,是选择压力最基础的来源。
分完层之后,两层算法的分岔点出现了。NSGA-Ⅱ用拥挤度距离来保证多样性,也就是优先保留那些在目标空间里分布稀疏的解。这个机制在二维、三维目标下效果不错,但目标维数继续增加时,拥挤度距离很容易被高维空间里大量非支配解稀释掉,种群会迅速挤在一起,失去多样性的意义。
NSGA-Ⅲ不再靠拥挤度,而是预先在目标空间里生成一批均匀分布的参考点,然后让每个个体找到距离自己最近的参考点,属于同一个参考点的个体互相竞争。这样就相当于把高维空间切成了一个个小区域,每个小区域都要有代表解,多样性自然保住了。
3.2 归一化、关联和niching:NSGA-Ⅲ的精髓
NSGA-Ⅲ每轮迭代都要做三件关键事。第一件事是目标归一化。不同目标的量纲差得非常大,发电量可能是几万兆瓦时,耗水量可能是几亿立方米,排放量又可能只有几千吨。不归一化的话,参考点坐标就毫无意义。归一化通常用种群当前的最优理想点和最差点构造截距,把每个目标压缩到零到一的区间。
第二件事是关联操作。归一化之后,每个参考点对应一条从原点出发的参考线,个体计算到每条参考线的垂直距离,选最小距离的那个参考点作为归属。距离越小,说明这个个体越接近该方向的“最优”。
第三件事是小生境计数和保留。如果某个参考点已经有代表解,新加入的个体会被限制,防止一个方向挤太多。如果一个参考点完全没人占,它反而会被特殊照顾,从距离最近的层里挑一个解填进去。这个“照顾空区域”的机制,就是NSGA-Ⅲ在四目标、五目标问题里仍然能拉开前沿的关键。
3.3 遗传算子与约束可行性法则
调度问题的决策变量往往有强约束,比如发电流量不能为负、库容不能越界。直接用模拟二进制交叉和多项式变异生成的子代,一定会有大量越界分量。我试过把所有越界分量直接修正到边界,这样虽然保证变量合法,但容易导致种群多样性下降,尤其是变异算子刚把某个分量推出去,立刻又被打回边界,探索能力就白白浪费了。
最后我采用的方法是:交叉后做边界截断,变异后随机重初始化一小部分分量。也就是说,一个子代个体里,如果某一个分量越界,要么被拉回边界,要么以一定概率重新在上下限内随机赋值。这个方法保留了遗传算法自身的随机探索能力,也不破坏约束边界。
约束处理方面,我用了Deb的可行性法则:两个个体比较时,可行的优先于不可行的;两个都可行时,按非支配关系比较;两个都不可行时,约束违反量小的优先。代码里给每个个体维护了一个总约束违反量 violation,所有等式和不等式约束都换算成违反量求和。
4. Matlab代码实现:框架、核心函数与参数配置
4.1 代码整体结构与运行流程
我的Matlab工程目录分得很清楚:main.m、problem_data.m、nsga3.m、evaluate.m、crossover_mutation.m、result_plot.m。main.m负责读取数据、初始化种群、调用主循环、输出结果。problem_data.m定义所有电站参数和负荷曲线。nsga3.m是算法主循环,每一步进化后调用选择过程。evaluate.m是模型核心,输入一个体,输出目标和约束。
主流程大致是:
- 加载算例数据,初始化 N 个个体。
- 计算每个个体的目标值和约束违反量。
- 进入进化循环:父代通过锦标赛选择产生交配池。
- 对交配池做交叉和变异,生成子代种群。
- 合并父代和子代,得到规模为 2N 的群体。
- 对这 2N 个个体做非支配排序。
- 从排序结果里逐层加入下一代,直到填满 N 个。
- 最后一层不能全放时,用参考点小生境选择决定放哪些。
- 达到最大进化代数后,输出第一层级的所有解作为帕累托前沿。
这个流程是一个标准NSGA-Ⅲ骨架,我自己在写的时候也参考了Deb公开的版本,但把数据结构简化成了Matlab struct,更适合调度问题快速二次开发。
4.2 核心函数拆解:初始化、交叉变异、环境选择
初始化的关键不是random函数,而是“怎么让初始种群合理分散”。纯随机生成的调度方案,大概率连负荷平衡都满足不了,约束违反量普遍偏大,进化起来前几十代都在修复可行性。
我用了启发式初始化:把负荷平均分给火电和水电,水电内部再按时段来水比例分配发电流量,在这个基准上叠加小扰动。实测下来,种群初始可行性肉眼可见地提高,收敛速度也快了不少。
交叉变异部分的核心函数大概是这样的:
function [child1, child2] = sbx_crossover(parent1, parent2, lb, ub, eta_c, prob_cross) child1 = parent1; child2 = parent2; if rand < prob_cross for j = 1:length(parent1) if rand <= 0.5 u = rand; if u <= 0.5 beta = (2*u)^(1/(eta_c+1)); else beta = (1/(2*(1-u)))^(1/(eta_c+1)); end child1(j) = 0.5*((1+beta)*parent1(j) + (1-beta)*parent2(j)); child2(j) = 0.5*((1-beta)*parent1(j) + (1+beta)*parent2(j)); child1(j) = min(max(child1(j), lb(j)), ub(j)); child2(j) = min(max(child2(j), lb(j)), ub(j)); end end end end多项式变异类似,只是作用在单个个体上。真正花时间的是环境选择:需要在合并种群上先做非支配排序,再调用参考点生成、归一化、关联、小生境筛选。参考点生成用的是Das-Dennis方法。我用的目标数是3,因此将每个维度划成13份,参考点数量是组合数C(13+3-1, 2)=105,也就是C(15, 2)=105。这个数量通常够用了。
4.3 约束惩罚与不可行解的过滤
代码里如果一个解严重违反水量平衡约束,直接进入可行解梯队之前就可能被排序挤出。但这个“挤出”的力度要控制好,否则算法会过早放弃探索极端但可能存在修复空间的区域。我把总约束违反量做了归一化,除以所有个体中最大违反量,然后按可行性法则参与排序。这样不可行度高的解在后期会被淘汰,但在前期还能参与交叉产生新信息。
在最终输出帕累托前沿时,我只会保留完全可行的解。对违反量小于 1e-6 的解,认为约束满足。这里要提醒一下:不同约束的尺度差别很大,库容约束的违反量可能是万方级别的,负荷平衡的违反量可能是兆瓦级别的,如果直接求和,量级大的约束会完全淹没量级小的约束。所以每个等式约束要除以一个基准值,把违反量压缩到同一量级。这个我在evaluate函数里提前处理好了。
5. 算例分析与结果可视化
5.1 测试环境与数据准备
我用了一个简化但保留物理特征的算例:三座梯级水电站,一座火电厂,调度周期24小时,步长1小时。来水数据取的是枯水期典型日,负荷曲线取的是早晚两个峰。火电参数参考典型燃煤机组,最大出力600MW,最小技术出力150MW,爬坡速率每小时60MW,排放系数用手册常用值。
Matlab版本我用的是R2023a,不需要额外工具箱,只用到了基础函数和统计工具箱里的随机数生成函数。整个算例种群规模设为200,进化代数500。一次完整运行在普通台式机上的耗时大约两分钟,如果目标函数里没有查表插值而是逐时段循环,时间会翻好几倍。
5.2 帕累托前沿的形态分析
跑完之后,我习惯先把帕累托前沿投影到二维平面上看。三目标情况下,可以分别看发电量-耗水量、发电量-排放量、耗水量-排放量三张投影图。发电量-耗水量投影通常是一条单调上升的曲线:多发水电必然多耗水,这个关系非常直观。
有意思的是第三张投影,耗水量和排放量之间不是简单的线性关系。来水集中在梯级上游某座电站时,下游电站的流量衔接会影响整体耗水效率,有些解耗水量更少但排放也更低,这说明找到了一个更优的水火互补节奏。这种信息只有多目标计算才能暴露出来,单目标优化基本看不到。
5.3 收敛性与多样性评估
我对算法是否收敛的判断,不是只看最终帕累托前沿是否漂亮,而是看进化过程有没有继续改进。三目标调度问题没有真实最优前沿可以对照,我用超体积指标来评估:每50代计算一次当前非支配解集相对于参考点的超体积,观察曲线是否趋于平稳。
多样性方面,最简单有效的方法是统计最终前沿上解的“均匀程度”:计算每个解在目标空间里的最近邻距离,画成直方图。NSGA-Ⅲ跑出来的结果,直方图往往比NSGA-Ⅱ更集中在一个窄区间,这说明解面铺得更匀。我在做算法对比时,把NSGA-Ⅲ和NSGA-Ⅱ各自独立运行10次,比较超体积的均值和标准差。NSGA-Ⅲ整体超体积更高,离散度也更小,四目标以上的差距还会更明显。
6. 调参经验与常见坑
6.1 种群规模、进化代数、参考点数量怎么搭配
这些参数看着给一个默认值就能跑,但实际调起来有讲究。参考点数量如果超过种群规模,会出现很多参考点没有分配个体的情况,小生境保留机制基本失效。一般建议种群规模是参考点数量的1.5到2倍。三目标参考点选105个时,种群规模至少150到210,我用200比较顺手。
进化代数取决于问题复杂度。如果约束不怎么紧,200代就能出形;约束紧的时候,前200代大部分个体还在慢慢靠近可行域,真正拉开前沿要到350代以后。保守起见我建议先用300代试跑,看超体积曲线有没有平台期,没平台就加代。
交叉分布指数建议设在10到20之间,变异分布指数设在20左右。变异概率建议设成1/变量维数,这是个经验公式,我实测下来比固定某个数值更稳。
6.2 几个让我浪费时间的问题
第一个坑是Matlab版本差异导致的随机数生成器行为不同。旧版本默认用twister,新版本改成更严格的生成器,同一套种子跑出来的结果不一样。这不影响算法本身,但如果你要把某篇文章的结果复现出来,一定要记录Matlab版本和随机数流设置。
第二个坑是向量化不彻底导致的性能灾难。我最初在evaluate函数里对72个决策变量逐维计算,24个时段内层再套一层循环,一次种群评估接近一秒,500代跑下来快半小时。改成矩阵运算和预计算查表后,单次评估降到几十毫秒。调度仿真这种代码,性能不是锦上添花,而是能不能迭代调参的关键。
第三个坑是约束违反量的量纲问题。这个前面提过,但真的值得再强调一遍。我第一次跑出来的帕累托前沿全是“可行”的,但实际水电库容轨迹在低谷时段有明显顶到边界的现象,只是违反量和其他尺度大得多的约束混在一起,被稀释到看不出异常。后来给每个约束单独设计了违反量系数,问题才暴露出来。
6.3 从仿真到工程落地的一些思考
这套代码作为学术研究和预研工具,算能打的了。但真要往实际调度系统上转,还有几个方向值得继续改。一是引入更细的水头修正,梯级电站在日调节内水头变化很明显,固定水头模型会高估发电量。二是把机组尺度的组合启停加进决策变量,这会显著增加问题维度,NSGA-Ⅲ的高维优势更能发挥出来。三是把预测不确定性和风险约束加进去,比如来水偏差和负荷预测误差,这样解出来的前沿才更贴合调度员的真实决策空间。
就我个人经验来说,先跑通一个三层梯级加单火电的三目标算例,理解NSGA-Ⅲ的代码逻辑和帕累托前沿的读法,比直接上十多个电站的巨型模型要靠谱得多。模型复杂度可以后面逐步加,算法框架和代码架构一定要一开始就立稳。你在自己的Matlab工程里照这个思路搭,大概率能少走几个月弯路。