1. 为什么综合能源调度必须引入多目标优化
我第一次尝试搭建综合能源系统调度模型时,用的是最传统的单目标线性规划,目标函数只设运行成本最小。跑出来的结果非常“偏科”——系统会尽量用最便宜的电,哪怕这台机组碳排放高得离谱;储能设备几乎不参与调节,因为成本模型里压根没有体现环境代价。导师看完结果只问了一句:“如果明天碳配额价格涨一倍,你的方案还算最优吗?”
这个问题点醒了我。所谓综合能源系统,本质上是电、热、气多种能源耦合运行的系统,风电、光伏、燃气轮机、电锅炉、储能、需求响应等设备在同一张网里互相制约。调度的目标从来不止一个:运行成本要低、碳排放要少、弃风弃光要小、系统可靠性要有保障。这些目标之间往往相互打架——想降成本就可能多烧煤电,想减排就得让高成本机组多出力。单目标优化只能通过加权求和把这些目标硬揉成一个数,权重怎么定全靠拍脑袋,而且当Pareto前沿非凸时,加权法连某些有效解都找不到。
这时候就需要真正的多目标优化算法。NSGA-II,全称Non-dominated Sorting Genetic Algorithm II,也就是带精英策略的非支配排序遗传算法,是目前解决这类问题最成熟的进化算法之一。它的输出不是单个最优解,而是一组Pareto最优解集——这批解相互之间不存在绝对优劣,成本最低的方案排放可能最高,排放最低的方案成本可能最高,工程师根据实际场景从中挑一个最合适的。这种“先给一揽子选择、再人工决策”的思路,和综合能源调度这种多目标冲突强烈的场景几乎是天生一对。
本文分享的就是我在Matlab环境下,从零实现NSGA-II求解综合能源日前优化调度的完整过程,包括问题建模、算法核心代码、约束处理方法和实际调参经验。无论你是刚开始接触综合能源方向的研究生,还是在工程中需要做多目标运行优化的工程师,这套代码框架都可以直接改改就用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先把调度问题翻译成数学模型:目标函数、决策变量与约束
2.1 场景假设与决策变量定义
我这里以一个典型的园区级综合能源系统为例:系统包含风力发电、光伏发电、燃气轮机、电锅炉、蓄电池、蓄热罐,外部电网可以购电购气,内部负荷分为电负荷和热负荷。调度周期取24小时,时间分辨率为1小时。
这组决策变量要说清,后面写代码全靠它:
- 燃气轮机每小时的电出力 P_gt(t)
- 电锅炉每小时消耗的电功率 P_eb(t)
- 蓄电池每小时充放电功率 P_bat(t)(放电为正,充电为负)
- 蓄热罐每小时充放热功率 H_ts(t)(放热为正,充热为负)
- 从外部电网购买的电功率 P_grid(t)
- 从外部购买天然气折算成功率 P_gas(t)
每个变量都对应24个时段的取值,所以整个优化问题的维度是 6 × 24 = 144 维。进化算法处理这种维度没有任何压力,这也是为什么NSGA-II这类元启发式算法在能源调度领域这么流行——它对问题维度不敏感,不像动态规划那样随状态变量爆炸式增长。
2.2 两个目标函数:成本与碳排放
目标函数一,系统总运行成本最小。这里要计入购电费用、购气费用、设备运维费用,减去售电收益(如果有余电上网的话),同时把储能设备的折旧成本按充放电量折算进去:
min f₁ = Σ [ c_grid(t)·P_grid(t) + c_gas(t)·P_gas(t) + Σ c_om,i·P_i(t) ]
其中 c_grid(t) 和 c_gas(t) 是分时电价和气价,这个分时特性很重要——很多调度策略能省钱并不是设备效率高,而是把用能行为挪到了低谷时段。c_om,i 是第i台设备的单位运维成本,实际算的时候设备越多,这部分越琐碎。
目标函数二,碳排放量最小。碳排放主要来自外购电和天然气燃烧:
min f₂ = Σ [ ε_grid ·P_grid(t) + ε_gas·P_gas(t) ]
注意外购电的碳排放系数 ε_grid 要按当地电网的平均排放因子来取,不同地区差别很大。燃气的排放因子可以按甲烷燃烧的化学计量关系推算,也可以直接查标准。另外,如果有碳捕集装置,这部分模型会更复杂,需要加额外的能量惩罚项。
2.3 约束条件的工程化处理
约束条件是整个调度模型里最容易翻车的部分,写少了结果不物理,写多了算法半天收敛不了。我实际建模时保留了以下几类:
功率平衡约束是最硬性的。电力平衡要求发电加购电,减去电锅炉耗电和储能充电,等于电负荷。热力平衡要求燃气轮机余热回收、电锅炉产热、蓄热罐放热之和等于热负荷。这两个等式约束如果不满足,整个调度方案就是空中楼阁。
设备出力上下限和爬坡约束也不能省。燃气轮机不能超过额定功率,蓄电池充放电功率有限,储能SOC要保持在安全区间(比如0.1到0.9),而且SOC的时序递推关系要写对:
SOC(t+1) = SOC(t) + η_ch·P_ch(t)·Δt - P_dis(t)·Δt/η_dis
这个递推关系在代码里就是一个循环,但很多人第一次写容易把充放电效率的位置搞反。充电时效率乘在输入侧,放电时效率除在输出侧,这样能量守恒才闭合。
备用约束是综合能源系统里容易被忽略的一环。考虑到风电和光伏出力有预测误差,系统要留出一定旋转备用容量,对燃气轮机的可用容量提出额外要求。这个约束会让可行域显著变小,如果你发现NSGA-II跑出来的解大量越界,先查备用约束是不是定得太苛刻。
2.4 处理约束时用的罚函数策略
在NSGA-II这类进化算法里,我试过多种约束处理方式,最后还是回归了罚函数法。不是因为它最优雅,而是它实现简单、调试方便,而且和NSGA-II的非支配排序天然兼容。
具体做法是对每个决策变量编码产生的候选解,先计算所有约束的越界量,再累加成惩罚项加到两个目标函数上:
f'_1 = f₁ + λ₁·Σ violation
f'_2 = f₂ + λ₂·Σ violation
λ 是惩罚系数,太小会导致大量不可行解混进Pareto前沿,太大会让算法因为目标函数过于陡峭而难以收敛。这个系数我后面还会细说怎么调。
3. NSGA-II的三个核心机制:非支配排序、拥挤度距离、精英保留
3.1 非支配排序如何给解分层
NSGA-II的算法框架其实不复杂,但理解“非支配”这个概念是所有逻辑的起点。两个解A和B,如果A在所有目标函数上都优于或等于B,而且至少在一个目标上严格优于B,那么就说A支配B。反过来如果A在成本上小、B在排放上小,那它们互不支配,都属于第一层Pareto前沿。
所谓非支配排序,就是把种群中当前所有非支配解挑出来,标记为第1层;把这些解临时移除,再挑剩下解中的非支配解,标记为第2层;如此循环,直到所有解都被分层。
这个操作的时间复杂度,原始版本是O(M·N²),M是目标数,N是种群规模。后来有人提出用排序方法优化到O(M·N·logN),但在实际能源调度问题中,N=200甚至100就已经很快了,朴素实现完全够用。
3.2 拥挤度距离维持解的多样性
非支配排序只解决“优劣分层”的问题,但同一层内部的解怎么比较?如果只按分层选择,算法很容易在某个区域堆满解,Pareto前沿覆盖性差。NSGA-II用的机制是拥挤度距离——对同一非支配层的所有解,按每个目标函数值排序,计算每个解与相邻两个解在每个目标方向上的归一化距离之和。
简单理解,拥挤度距离大的解,说明它周边的解稀疏,保留它可以让解集分布更均匀。在精英保留环节,同一层内优先保留拥挤度距离大的解,这样前沿不会“挤成一团”。
3.3 锦标赛选择、SBX交叉和多项式变异
进化部分用的是经典套路。选择操作是二元锦标赛——随机挑两个个体,先比非支配等级,等级小的胜出;等级相同比拥挤度距离,距离大的胜出。这样兼顾了收敛性和多样性。
交叉算子对实数编码问题我习惯用模拟二进制交叉(SBX)。它的特点是子代在父代附近的搜索强度大,分布指数η_c通常取20左右。两个父代个体 p1、p2 在某个分量的交叉公式是:
c1 = 0.5·[(1+β)·p1 + (1-β)·p2]
c2 = 0.5·[(1-β)·p1 + (1+β)·p2]
其中 β 由随机数 u 和分布指数决定。这个算子的好处是它不产生超出父代范围的极端值(在β为0时子代在中间,β越大越向两侧),配合边界约束处理比较自然。
变异算子用多项式变异,分布指数 η_m 常取20或50。每个维度的变异概率取1/维度数,这样平均每个个体每次变异约一个分量,既维持了种群多样性又不至于让好解被破坏。
3.4 精英保留策略:为什么NSGA-II不容易丢最优解
NSGA-II每一代的做法是:把父代种群 P 和子代种群 Q 合并成规模为2N的临时种群 R,对 R 做非支配排序,然后按层级从低到高依次填入新一代种群,填满N为止。最后一层如果空间不够,就按拥挤度距离从大到小选择。
这个合并再选择的策略保证了任何一代中表现最优的个体(即使它不在子代里)都有机会继续参与下一代进化,这就是精英保留的核心价值。很多早期多目标算法丢最优解就是因为没有这个环节,后代种群完全依赖上一代的交叉变异,运气不好就丢了。
4. Matlab代码实现:从主循环到可视化一整套框架
4.1 代码结构总览
我的整套实现分为以下模块,每个模块对应一个 .m 文件,职责非常单一:
| 文件 | 功能 |
|---|---|
| main.m | 参数设置、初始化、主循环、结果输出 |
| init_pop.m | 生成初始种群,保证变量不越界 |
| evaluate.m | 计算每个个体的目标函数值和约束越界量 |
| non_dominated_sort.m | 快速非支配排序 |
| crowding_distance.m | 计算拥挤度距离 |
| selection.m | 二元锦标赛选择 |
| crossover_mutation.m | SBX交叉 + 多项式变异 |
| plot_results.m | 绘制Pareto前沿和调度计划图 |
4.2 个体数据结构设计与初始化
我这里每个个体用一个结构体数组存,字段包括 position、cost、violation、rank、crowd。相比用矩阵存,结构体写起来直观,调试的时候看数据也方便。唯一的缺点是内存开销稍大,但N不超过500时无感。
初始化时每个位置分量在上下界之间均匀随机取值。一个容易踩的坑是蓄电池和蓄热罐的充放电功率,因为它们的取值范围是负到正(充电为负、放电为正),如果随机初始化时不控制初始SOC,后续递推很容易在第一小时就超过SOC上下限。
我处理的办法是初始化时刻强制设 SOC(1) = 0.5,然后按SOC递推公式生成后面所有时段,这样即使充放电功率在边界,SOC也大概率落在合理区间。这不是什么高深的技巧,但确实让初始可行解的比例从不到10%提升到了70%以上,收敛速度肉眼可见地加快。
4.3 主循环核心代码
主循环就是标准的NSGA-II流程,代码非常紧凑:
matlab复制% 主循环
for gen = 1:maxGen
% 选择父代
parent = selection(pop);
% 交叉变异生成子代
offspring = crossover_mutation(parent, param);
% 合并种群
combined = [pop, offspring];
% 计算目标函数和约束越界
for i = 1:length(combined)
combined(i) = evaluate(combined(i), data);
end
% 非支配排序
combined = non_dominated_sort(combined);
% 拥挤度距离计算(对每一层分别算)
combined = crowding_distance(combined);
% 环境选择:填满新种群
newPop = [];
layer = 1;
while length(newPop) + length(combined(combined.rank == layer)) <= popSize
newPop = [newPop, combined(combined.rank == layer)];
layer = layer + 1;
end
% 最后一层按拥挤度填入
lastLayer = combined(combined.rank == layer);
[~, idx] = sort([lastLayer.crowd], 'descend');
newPop = [newPop, lastLayer(idx(1:popSize-length(newPop)))];
pop = newPop;
% 记录当前代Pareto前沿
front = pop([pop.rank] == 1);
fprintf('Gen %d: Pareto front size = %d\n', gen, length(front));
end
4.4 非支配排序函数的Matlab实现
这是整个算法最核心的环节,单独讲一下。朴素实现直接双重循环遍历两两比较:
matlab复制function pop = non_dominated_sort(pop)
N = length(pop);
M = size(pop(1).cost, 2);
[pop.rank] = deal(0);
for i = 1:N
dominated = false;
for j = 1:N
if i == j, continue; end
% 判断j是否支配i
better = all(pop(j).cost <= pop(i).cost);
strict = any(pop(j).cost < pop(i).cost);
if better && strict
dominated = true;
break;
end
end
if ~dominated
pop(i).rank = 1;
end
end
currentRank = 1;
remaining = find([pop.rank] == 0);
while ~isempty(remaining)
currentRank = currentRank + 1;
% 在剩余个体中找非支配解
for idx = remaining'
dominatedByRemaining = false;
for j = remaining'
if idx == j, continue; end
better = all(pop(j).cost <= pop(idx).cost);
strict = any(pop(j).cost < pop(idx).cost);
if better && strict
dominatedByRemaining = true;
break;
end
end
if ~dominatedByRemaining
pop(idx).rank = currentRank;
end
end
remaining = find([pop.rank] == 0);
end
end
Matlab里写循环确实不是性能最优的选择,但这个函数的规模是N²级别,N=200时4万次比较在Matlab里也就是毫秒级别,完全不是瓶颈。真正的性能瓶颈反而是evaluate函数里对每个个体做约束计算的耗时,那个涉及SOC递推和爬坡校验,没法完全向量化。
4.5 约束越界量的统一计算
我在evaluate.m中,目标函数和约束校验是分开写的。先算目标,再算约束:
matlab复制function ind = evaluate(ind, data)
% 解出决策变量
P_gt = ind.position(1:24);
P_eb = ind.position(25:48);
P_bat = ind.position(49:72);
H_ts = ind.position(73:96);
P_grid = ind.position(97:120);
P_gas = ind.position(121:144);
% 目标1:运行成本
cost_elec = sum(data.grid_price .* P_grid);
cost_gas = sum(data.gas_price .* P_gas);
cost_om = sum(data.om_gt) * sum(P_gt) + sum(data.om_eb) * sum(P_eb);
ind.cost(1) = cost_elec + cost_gas + cost_om;
% 目标2:碳排放
co2_grid = data.emission_factor_grid * sum(P_grid);
co2_gas = data.emission_factor_gas * sum(P_gas);
ind.cost(2) = co2_grid + co2_gas;
% 约束越界总量
violation = 0;
% 电力平衡:每个时段发电端与用电端之差
elec_balance = P_gt + P_grid - P_eb - P_bat - data.elec_load;
violation = violation + sum(max(0, abs(elec_balance) - 1e-3));
% 热力平衡
heat_balance = data.heat_recovery(P_gt) + data.eb_heat(P_eb) + H_ts - data.heat_load;
violation = violation + sum(max(0, abs(heat_balance) - 1e-3));
% SOC递推与上下限
SOC = zeros(1, 25);
SOC(1) = 0.5;
for t = 1:24
SOC(t+1) = SOC(t) + data.bat_eff_ch * max(0, -P_bat(t)) - max(0, P_bat(t)) / data.bat_eff_dis;
violation = violation + max(0, SOC(t+1) - 0.9);
violation = violation + max(0, 0.1 - SOC(t+1));
end
ind.violation = violation;
end
4.6 绘图与结果输出
我习惯在最后绘制三个图:Pareto前沿散点图、24小时电功率平衡堆叠图、蓄电池SOC和蓄热罐充放热曲线。Pareto前沿图让人一眼就能看出优化的效果,而堆叠图能直观呈现调度计划是否合理——比如凌晨风电大发时段能不能给电池充电,白天用电高峰是否是燃气轮机在顶负荷。
5. 从Pareto前沿到调度方案:结果怎么看、怎么选
5.1 一张Pareto前沿图能告诉我们什么
跑完算法,你最先得到的是一个二维散点集合,横轴是运行成本,纵轴是碳排放量,这些点分布在一条向左下凸的曲线上。这条曲线越靠近左下角,说明系统越“又省又环保”。我第一版参数跑出来的前沿上有很多点集中在中间地带,两边端点覆盖不足,说明初始种群多样性不够,或者变异率太低。把变异概率从1/维度数提高到3/维度数后,两端的解明显增多。
如果Pareto前沿的形状出现明显的“断崖”或者“分层”,我基本能断定要么是某个约束设置不当,要么是目标函数的尺度差异太大导致排序失衡。
5.2 三个典型解的选择逻辑
拿到Pareto前沿后,我通常有意识地找三个解来对比:最低成本解、最低排放解、和一个折中解。折中解我用的是最大最小距离法,选择离两个目标最坏值都较远的点,相当于“最不后悔”的选择。
实际做决策时,这几个解之间的差异往往非常有信息量。在我这个算例里,最低成本解和最低排放解的成本差大约18%,碳排放差大约25%。也就是说,如果碳价超过某个阈值,环保方案反而变成经济方案,这个阈值就是两个解的成本差除以排放差。这种信息只有多目标优化能给,单目标加权法根本看不见。
5.3 约束满足度的逐项检查
算法跑完后,第一件事不是盯着Pareto前沿感叹,而是检查每个解的约束越界量是否为0。在罚函数下,总会有少数解带着轻微越界混进Pareto前沿(罚得不重的时候)。我用一段脚本把所有Pareto解的violation打印出来,如果发现某个解的越界量明显大于0,说明惩罚系数太小或者约束过紧,需要调整后重新跑。
经验上,如果超过20%的Pareto解violation大于0.01,这个结果基本不能直接用,必须回头调参。
6. 参数调优与踩坑记录:实测下来的经验总结
6.1 种群规模和迭代次数的平衡
我试过三组配置:100个体×200代、200个体×200代、200个体×500代。结果很典型——100×200的前沿明显不光滑,端点解经常缺失;200×200已经能看出一条像样的曲线;200×500的提升主要体现在解的分布均匀性上,但运行时间翻了2.5倍。对于日前的调度问题,优化时间一般要求在十分钟以内,所以我最终用的是200×300,既稳又快。
6.2 交叉和变异概率的敏感性测试
SBX的分布指数η_c我固定为20,多项式变异的η_m在20和50都试过。差异最明显的其实是变异概率。取1/维度作为变异概率时,收敛快但解集多样性差;取3/维度时,前期探索能力强,后期稍微震荡,但最终Pareto覆盖范围更好。在多目标问题里,我宁可牺牲一点点收敛速度也要保多样性,因为你需要的是“整条曲线”而不是“最佳点”。
6.3 惩罚系数到底怎么调
这个我踩过最深的坑。λ取太小,不可行解嚣张地占据了Pareto前沿的半壁江山;λ取太大,目标函数梯度陡峭,算法基本上退化成单目标搜索。我最后用的策略是“自适应罚函数”的简化版:每一代统计种群中不可行解占比,如果超过40%就把λ乘以1.2,低于10%就除以1.2,让它自己动态平衡。效果比固定λ好得多,尤其在初期探索阶段非常管用。
6.4 随机种子的影响
很多人忽略这一点。NSGA-II是随机算法,不同随机种子跑出来的Pareto前沿会有微小差异,如果差异很大说明算法还没收敛或种群多样性不足。我每次实验固定跑五次取中位数结果。另外,正式实验前务必用rng(1)固定随机种子,否则论文里的结果根本没法复现——这个教训我付出了两天的重复实验代价。
6.5 一个具体的问题排查案例
有一次,我跑出来的燃气轮机出力在凌晨时段频繁触及下边界,但电负荷并不低,系统还在大量购电。一看数据就明白了——凌晨电价低谷期,购电比燃气轮机自己发电便宜得多,算法很诚实地做出了“省成本”的选择。但实际系统里燃气轮机还有供热任务,它的余热回收是热负荷的重要来源,单纯看电平衡并不能发现这个问题。
这个案例说明,综合能源调度模型最容易出的问题不在算法,而在能量耦合关系有没有写对。电锅炉和燃气轮机余热回收之间的热力分配,是系统耦合的命门,模型里必须有明确的表达式把不同设备的供热能力关联起来,否则算法给出的解在物理上根本不成立。我在evaluate函数里特意加了热力平衡和机组最小出力联动的校验逻辑,才算真正把坑填上。
7. 后续扩展方向与个人体会
这套NSGA-II框架跑通之后,扩展方向非常多。比如把风光出力的预测误差引入模型,用场景法或鲁棒优化处理不确定性,就变成了考虑随机性的多目标调度。或者把目标函数增加一个“系统能效”指标,从两目标变成三目标,NSGA-II同样能处理,只是Pareto前沿可视化要改用三维散点图。还可以把算法换成NSGA-III处理高维目标,或者引入混合整数规划编码处理机组启停的离散变量。
我在实际使用中发现,Matlab代码实现NSGA-II的最大优势是调试方便——每一步都能断点查看种群状态,配合workspace的变量可视化,能快速定位是模型问题还是算法问题。缺点是运行速度不如C++或者Python的numba加速版本,但对于24时段规模的调度问题,完全够用。
最后分享一个减少返工的小技巧:写代码之前,先把约束条件和目标函数用纸笔写成清晰的公式清单,标出哪些是等式约束、哪些是连续变量的上下限、哪些是时序耦合约束(如SOC递推)。这看起来是笨功夫,但能省下大部分“模型跑飞了不知道哪里错”的排查时间。
这套代码我目前还在持续维护,如果你也正在做综合能源调度的多目标优化,欢迎一起交流参数调优的心得。
