做综合能源系统(IES)优化规划的项目时,很多同学卡住的点,往往不是"模型建不出来",而是"模型一建出来,求解器跑不动"。设备多了、场景多了、时序长了之后,混合整数规划(MIP)的规模指数级膨胀,直接交给通用求解器,要么内存爆掉,要么算到天荒地老。而广义Benders分解法(GBD)恰恰是针对这种"整数变量负责选型、连续变量负责调度"结构的经典解法。我最初接触这个方向是在某园区综合能源规划项目里,当时用Matlab实现了一整套基于GBD的两阶段优化代码,从模型推导到算法调试,中间踩了不少坑,这篇就把完整思路、代码骨架和避坑经验一次性梳理出来,给正在做类似课题的人一个可直接上手的参考。
1. IES规划问题的模型解剖与分解动因
1.1 规划模型里那三类变量,为什么混在一起难处理
综合能源系统优化规划,抽象到数学层面,本质上是一个混合整数线性规划(MILP)问题,如果再考虑非线性的设备效率曲线,还会升级为混合整数非线性规划(MINLP)。但不管是哪一层,变量都可以清晰分成三类:
- 选型变量:设备的投建台数、容量档位,典型是整数变量或0-1变量,比如燃气轮机装几台、光伏装机接不接、储能容量选哪个档位;
- 运行状态变量:机组启停状态、储能充放标志,属于0-1整数变量;
- 连续调度变量:每个时段各设备的出力、储能SOC、购电购气量,属于连续变量。
这三类变量耦合在一起,约束也很典型:电热冷气四种能量的平衡约束、设备出力上下限、爬坡约束、储能SOC递推约束、节点潮流约束,还有投资预算约束。最开始我试着把所有变量一次性丢给求解器,小规模测试没问题,但只要把时间尺度从24小时拉到8760小时,或者把候选设备类型增加到七八种,问题规模立刻膨胀到几十万约束、十几万变量,其中整数变量就有几千个,直接求解的求解器基本处于"假死"状态。
为什么难处理?因为整数变量和连续变量之间是"结构耦合":整数变量决定系统拓扑结构,连续变量决定在给定结构下的最优调度。这种耦合导致分支定界树异常庞大,每个节点都要重新求解一个庞大的LP问题,整体计算量完全不可控。而实际规划决策真正需要"仔细枚举"的部分,是整数变量那一层,连续层本质上只需要做线性规划(LP)。
1.2 问题的结构化特征:为什么Benders是天然匹配
当时我意识到,这个IES规划模型有一个非常关键的结构特性:一旦把整数变量固定下来,剩余模型就是一个纯连续线性规划。举个例子,先假设燃气轮机装2台、储能选500kWh档位,那么设备容量就确定了,剩下的问题就是"在给定设备组合下,如何安排各设备逐时出力使年运行成本最低",这完全是一个LP问题,商用求解器秒解。
这正是Benders分解法的天然适用场景。Benders把原问题拆成一个主问题和一个子问题:
- 主问题(Master Problem)只保留整数变量、投资成本和一个用来逼近运行成本的辅助标量eta;
- 子问题(Subproblem)固定整数变量后,单独求解连续变量的运行调度问题,并把运行成本的"真实形状"用割平面反馈给主问题。
我在实际做的时候,最喜欢用一句大白话跟同学解释这个思路:主问题拍脑袋先选一套设备配置,然后交给子问题去算"这套配置好不好、运行成本到底多少、成本还能不能继续压下去",子问题把"这套配置好到什么程度"告诉主问题,主问题据此调整下一轮选型。如此反复迭代,直到主问题的估计值和子问题的真实成本收敛到同一个值。
工具选型上,我直接在Matlab里用YALMIP建模,求解器选的是常用的商用求解器,YALMIP的好处是建模层和求解层分离,换求解器非常方便。主问题因为带整数变量,用MIP求解器;子问题固定整数后是LP,用同一个求解器也可以,速度快很多。
| 问题部分 | 保留变量 | 目标函数 | 求解器类型 |
|---|---|---|---|
| 主问题 | 整数选型变量 + eta辅助量 | 投资成本 + eta | MILP |
| 子问题 | 连续调度变量(整数固定) | 运行成本 | LP |
2. 广义Benders分解的算法逻辑:主问题-子问题的博弈
2.1 从经典Benders到广义Benders,到底"广义"在哪
经典Benders分解最早是应用在固定费用运输问题这类纯线性场景里的,它的核心是用线性对偶理论,把子问题的最优解信息转化为一条割平面约束,不断缩小主问题的可行域。而广义Benders分解(GBD)是经典版本的自然推广,把适用对象扩展到了更一般的凸优化问题,包括带有凸非线性约束的MINLP。两者的核心逻辑一致,GBD在理论上更抽象,也更贴近IES这类复杂系统的实际需求。
在IES规划的具体语境下,广义这两个字可以理解为三层扩展:
- 子问题不必是严格线性,可以是凸非线性问题,只要对偶信息可获取;
- 主问题中辅助变量eta和割平面方法可以直接处理包含非线性目标的问题;
- 可行性割的策略更丰富,当子问题不可行时,通过引入人工变量获得可行性割平面。
不过说实话,我在工程实践中大多数时候面对的IES规划,线性化后都是MILP,GBD的理论优势不一定能完全发挥,但用GBD的框架去理解代码流程,确实比直接用经典Benders的视角更清晰。
2.2 主问题、子问题、割平面三步曲
整个GBD算法循环拆开来看,其实就是三个步骤不断重复,直到收敛。我把它梳理成了一张标准流程,后面写代码时完全按这个来,不会乱。
步骤一:求解主问题。主问题的决策变量是整数选型变量x和辅助运行成本eta,目标是最小化投资成本与eta之和。但主问题本身不知道运行成本的真实函数,它只知道由割平面提供的一系列"运行成本关于选型方案的线性下界"。
主问题标准形式如下:
min c_inv^T * x + eta s.t. A_int * x <= b_int eta >= eta_lb [割平面约束集合,形式为 eta >= ... 或 0 >= ...]主问题求解完成后得到一组候选整数解x_k和最优目标值,主问题的目标函数值就是全问题的一个下界(Lower Bound,LB)。
步骤二:求解子问题。把x_k当成已知参数代入原问题,求解固定整数变量后的运行调度LP。此时子问题形式是:
min c_op^T * y s.t. A_op * y <= b_op - B_op * x_k这里的y是连续调度变量。子问题解出的最优值就是当前配置x_k下真实可达到的全年运行成本,也就是这个周期的最优成本上界的一个组成部分。这里有个细节:当前解的总成本(投资成本+运行成本)是全局最优值的上界(UB),因为它是原问题一个可行方案的实际总成本。
步骤三:生成割平面并回投主问题。子问题的求解结果分两种情况:
- 子问题可行,得到最优运行成本f_k和调度解y_k,同时通过求解对偶问题或直接从求解器提取拉格朗日乘子lambda_k,生成最优性割平面;
- 子问题不可行,说明主问题的候选整数解x_k根本不满足系统运行可行性,生成可行性割平面,砍掉这个选型方向,让主问题在下一轮调整整数决策。
这三步听起来简单,但每一步在实际代码里都有不少细节,下面第3节我直接展示Matlab实现时我自己写过的关键代码。
2.3 最优性割与可行性割的数学推导直觉
关于割平面的数学原理,我尽量不写太多纯推导,但作为实现者至少要知道公式从哪来。
对于可行的子问题,最优解处拉格朗日乘子给出了运行成本函数在x_k附件的一个支撑超平面。子问题的最优值f(x_k)满足:
f(x) >= f(x_k) + lambda_k^T * (x - x_k)把它代入主问题的eta变量约束,就得到最优性割:
eta >= f(x_k) + lambda_k^T * (x - x_k)这条线性约束的几何含义很直观:当主问题尝试换一个整数选型x时,它不能再悲观地猜运行成本很低,至少不能低于这个超平面给出的估计值。随着迭代主问题积累越来越多的割平面,eta的下界预估就越来越精确。
对于不可行的子问题,处理方式是给等式约束或不等式约束加上非负松弛变量,先求一个"最小违规"问题,再使用违规问题的对偶乘子生成可行性割,形式类似于:
0 >= v_k^T * (b_op - B_op * x)这一条切下去,等于告诉主问题:你之前选的设备组合要满足运行可行性还差得远,往一个更可行的方向去搜索。
提示:编写代码时务必将可行性割和最优性割分开记录,因为它们进入主问题时系数含义完全不同,混在一起会导致割平面完全失效。
3. Matlab代码实现:架构、流程与关键代码
3.1 建模数据结构与求解器调用约定
我先说代码的组织方式。整个项目我习惯用若干个function文件加一个主脚本。数据统一放到结构体变量里,命名为sys,里面包含负荷曲线、能源价格、设备参数和时间参数。不要用一堆全局变量,不然换数据、做灵敏度分析时容易乱套。
数据层面的关键字段大概是:
% 系统结构数据 sys.horizon = 24; % 优化周期,24小时或8760小时 sys.load.elec = load_elec; % 电负荷曲线,1x24 sys.load.heat = load_heat; % 热负荷曲线 sys.price.elec_buy = price_elec; % 购电价 sys.price.gas = price_gas; % 购气价 % 候选设备参数 sys.gen.gt.cap = [1000, 1500, 2000]; % 燃气轮机候选容量[kW] sys.gen.gt.inv = [1200, 1700, 2100]; % 对应单位投资成本[元/kW] sys.gen.gt.eff = 0.42; % 发电效率 sys.gen.eb.cap = [500, 800, 1000]; % 电锅炉候选容量 sys.gen.es.cap = [500, 1000, 1500]; % 储能候选容量[kWh] sys.gen.es.inv = 1500; % 储能单位投资成本[元/kWh]主问题的选型变量我习惯用z表示,是一个向量,每个元素对应一种候选设备是否投建;调度变量用p,每个元素对应e世代的出力。使用YALMIP时,主问题变量用binvar声明,子问题变量用sdpvar声明。
调用求解器的约定:主问题是一个MILP,用支持整数规划的商用求解器;子问题是标准LP,用同一个求解器就行。指定求解器这行代码写清楚,防止YALMIP自动选到一个不适合的求解器导致解不出来。
ops_master = sdpsettings('solver', 'gurobi', 'verbose', 0, 'gurobi.MIPGap', 1e-4); ops_sub = sdpsettings('solver', 'gurobi', 'verbose', 0, 'gurobi.FeasibilityTol', 1e-6);这里的MIPGap和FeasibilityTol我特意设得比默认值严格一点,因为GBD的收敛判据是看主问题下界和子问题上界的差,如果求解器自身精度太松,上下界会在最后阶段不停振荡。
3.2 GBD主循环的代码骨架
整个GBD代码的核心是一个while循环。我在项目里反复调整后的最终骨架如下,注释里包含了每个模块的作用。
% 初始化 x_init = zeros(ndev, 1); LB = -1e10; UB = 1e10; tol = 1e-4; max_iter = 50; cut_list = {}; % 存储所有割平面约束 feasible_cut_list = {}; for iter = 1 : max_iter fprintf('Iter %d: LB=%.4f UB=%.4f gap=%.6f\n', iter, LB, UB, abs(UB-LB)/abs(UB)); % ========== 1. 求解主问题 ========== [z_k, eta_k, LB] = solve_master(cut_list, feasible_cut_list, sys, ops_master); if LB >= UB - tol * abs(UB) break; % 已达收敛 end % ========== 2. 固定整数变量后求解子问题 ========== [f_sub, lambda_eq, lambda_ineq, feasibility_flag] = solve_sub(z_k, sys, ops_sub); % ========== 3. 生成割平面 ========== if feasibility_flag == true % 最优性割 UB = min(UB, investment_cost(z_k, sys) + f_sub); [expr_cut, model_type] = build_optimality_cut(z_k, f_sub, lambda_eq, lambda_ineq, sys); cut_list{end+1} = expr_cut; else % 可行性割 [expr_cut, model_type] = build_feasibility_cut(z_k, lambda_eq, lambda_ineq, sys); feasible_cut_list{end+1} = expr_cut; end if abs(UB - LB) <= tol * abs(UB) break; end end这段代码有两个关键点,稍微没做到位GBD就直接失效。第一,主问题的求解结果z_k必须是什么赋给子问题的?是变量变量的取值,不是主问题解中eta的取值。刚开始我把主问题的整数值变量取出后没做类型转换,YALMIP返回的是变量对象,直接传入子问题建模函数会报错或出现非线性项。正确做法是用value(z_k)取数值,再把数值带入子问题。
第二,LB更新是用主问题目标值,UB更新是用固定整数配置下的真实可运行成本。有些代码把子问题求解目标值直接当作UB,忘了加投资成本,这样上下界永远合不上,迭代必然发散。
3.3 割平面生成的关键实现细节
割平面生成是GBD最容易出错的地方,我单独把它拎出来说。在Matlab+YALMIP环境里,子问题求解完后,对偶乘子可以通过dual()函数提取。但这里有个很坑的细节:YALMIP对等约束和不等式约束的dual符号约定,与你自己手写对偶问题的符号可能差一个负号。
直接验证的方法很笨但非常有效:先取一个简单的小规模算例,手写对偶问题,逐个约束对比符号。我做过一次之后,就再也没被符号坑过。
最优性割的构造逻辑我贴一段核心代码,前提是子问题目标只含运行成本,外部变量z_k是一维向量:
function [cut_expr, type] = build_optimality_cut(z_k, f_sub, lambda_p, sys) % 根据子问题目标函数中的常数项、一次项系数构造割平面 % f(x) >= f_sub + lambda' * (x - z_k) % 目标:eta >= f_sub + lambda' * (x - z_k) % 假设 z 是 yalmip binvar z = sys.z_var; % 主问题中的整数变量,外部句柄传入 cut_expr = sys.eta_var >= f_sub + lambda_p' * (z - z_k); type = 'optimality'; end实际操作时我不会在子问题里重复定义z_var,而是把主问题的变量句柄一直保存到系统结构体里,传递给割生成函数。这个"传递变量句柄"的操作,YALMIP里是完全支持的,很多新手不理解变量对象可以当参数传递,结果在割平面里凭空多出一套不相关的变量,主问题规模越迭代越大。
可行性割的代码略有不同,它是基于不可行子问题中松弛变量的对偶乘子构造的:
function [cut_expr, type] = build_feasibility_cut(z_k, lambda_feas, sys) % 可行性割:保障主问题朝可用域收敛 cut_expr = 0 >= lambda_feas' * (sys.z_var - z_k); type = 'feasibility'; end这里有个经验:可行性割的右侧不一定只有0,需要根据你建模时松弛变量的具体形式做一点常数项偏移。最稳妥的办法是先把等价形式写纸上,再落代码,不要直接套模板。
注意:子问题求解一定要使用
value(z_k)而不是z_k本身。用变量对象直接代入约束,会让YALMIP误以为整个子问题还是含整数变量的MINLP,分解的意义就完全没了。
4. 实操中踩过的坑与排查经验
4.1 子问题不可行怎么办:可行性割的落地实现
做IES规划时,子问题不可行太常见了。比如主问题选了很小的储能容量,但负荷峰值时段要求电锅炉出力超过上限,或者燃气轮机爬坡跟不上,都会导致给定设备配置下根本没有可行的运行策略。
很多同学的第一个想法是"把不可行子问题的目标值设成一个很大的数return掉就算了",但这样GBD上下界永远收敛不了。正确做法是:在子问题里加入非负松弛变量,把不可行问题转化为"最小违规量问题"。
我的实现方式是,在子问题约束中预留一组松弛变量slack,每个等式/不等式约束都加上对应松弛项:
% 子问题约束示例 C_sub = [... P_gt + P_buy + P_pv - P_load == 0 + slack(1), ... % 电平衡 Q_gt + Q_eb - Q_load == 0 + slack(2), ... % 热平衡 ...其他约束... ];子问题目标从"最小化运行成本"调整成"最小化运行成本 + 一个足够大的惩罚系数乘以松弛变量之和"。当惩罚系数足够大时,如果原问题确实可行,松弛变量最优值会全为0;如果不可行,松弛变量给出最小违规量,此时的对偶乘子恰好可以用来生成可行性割。
但要小心三个陷阱:
- 惩罚系数太小,子问题宁可违规也不满足约束,生成错误的可行性割;
- 惩罚系数太大,子问题数值病态,对偶乘子不准确;
- 松弛变量加到等式约束时,正负两侧都要加,即
slack_plus - slack_minus,否则非线性或不可行。
我在项目里把惩罚系数设为运行成本量纲的1e6倍,配合求解器的高精度容差设置,数值上是稳定的。这个值没有统一标准,我建议用"最小二乘法估算",先随便给一个值跑一次,看子问题无违规时松弛变量是否精确为0,再决定是否调大。
4.2 收敛慢与上下界gap震动的常见原因
GBD代码跑通后,最头疼的不是报错,而是上下界卡在一个gap上不动,或者gap上下跳动。我遇到过几次,原因基本逃不脱下面几类:
数据量纲不一致。主问题里投资成本动辄百万,运行成本每时段可能只有几十块,割平面中lambda的数值跨度巨大,主问题数值条件数很差。解决办法是对量纲做归一化,把投资和运行成本统一成"万元"或者"年均成本归一化到同一数量级",我最后选择全部折算成年费用并除以一个基准倍数。
割平面冗余太多。GBD每轮都往主问题里塞一条割,迭代到20轮以后,主问题规模越来越大,求解时间反而上涨。但要注意,并不一定需要清空冗余割,因为割平面代表的是历史信息,全丢了可能丢到关键约束。我的做法是保留最近5轮的最优性割,可行性割全部保留,因为可行性割数量本来就少。
初始解质量差。第一次迭代的主问题如果没有好的整数初始解,第一轮割平面位置会很差,后面需要很多轮才能修正。解决方法是先解一个"松弛主问题",即把整数变量全部松弛成连续变量,得到初始点z_0,再回代子问题。这招能把迭代次数砍掉三分之一左右。
MIPGap设置太宽松。主问题本身是MILP,求解器返回的虽然是最优解,但实际MIPGap可能是1e-2量级,这个误差叠加到GBD的上下界计算里,导致外层判断误判。我前面提到的主问题MIPGap设置成1e-4,就是为了避免这个问题。
4.3 验证代码正确性的三板斧
GBD的代码链比较长,真出了错很容易被复杂流程掩盖。分享三个我每次开发完必做的验证手段,比较土但极其管用。
第一板斧:暴力枚举对照。把所有候选设备的整数组合全部枚举出来,每个组合直接求解完整MILP(在这种小规模下求解器可以处理),得到每个组合的真实最优值,与GBD的最终最优解对比。只要规模控制在候选组合数千以内,这个验证就是可靠的。很多论文里所谓"小算例验证",实际上就是干这个。
第二板斧:固定数据随机种子,逐轮检查单调性。GB D正常迭代时,下界LB应该单调递增,上界UB可以震荡但整体趋势应该单调递减。一旦出现LB回退,说明主问题加入割平面后反而放宽了原约束,或者主问题的求解器返回了次优解。我用一段脚本自动记录每轮的LB、UB、z_k,然后画曲线,看到LB非单调增长基本上就是割平面符号搞反了。
第三板斧:能量平衡约束的基姆检验。把最终回来的最优调度方案,手算子系统的能量平衡,逐时检查电、热、冷是否守恒。这一步只能发现物理模型的bug,但它能防止一种特殊情况——GBD把自己陷入一个数学上最优但物理上不可行的点,比如储能SOC循环不合理,这时候数值公式是自洽的,只有物理校核才能暴露问题。
5. 一个可快速上手的完整算例配置
5.1 算例数据与参数设计
为了帮还没有头绪的同学快速跑通程序,我这里给出一份我在自测中使用的参考算例配置。场景是某工业园区,包含燃气轮机、电锅炉、光伏和电池储能四种候选设备,时间尺度取24小时,负荷曲线和光伏出力都可以自己构造典型日数据。
参考设备参数的表如下:
| 设备 | 候选数量 | 备注 |
|---|---|---|
| 燃气轮机 | 3台候选 | 容量1000/1500/2000kW,发电效率0.42,可同时产热 |
| 电锅炉 | 3台候选 | 容量500/800/1000kW,效率0.95 |
| 光伏 | 固定接入 | 出力曲线按光照数据处理 |
| 电池储能 | 3档候选 | 容量500/1000/1500kWh,充放效率0.9 |
价格参数设计上我用了分时电价,峰时1.2元/kWh、平时0.7元/kWh、谷时0.35元/kWh,天然气价按3.2元/m³折算为每千瓦时热值成本。投资成本统一折算成年值,按照设备寿命20年、贷款利率5%用等额年金折算系数计算。
原始数据的预处理也会影响求解结果,我建议先用同样的曲线生成多组场景数据做稳定性测试,看看GBD在数据微扰下最优配置是否稳定。这比单纯跑通一次要有意义得多。
5.2 从主程序到子程序的文件组织
整份Matlab代码我建议拆成以下几个文件:
main_gbd_ies.m:主循环入口,负责初始化数据和迭代逻辑;build_ies_model.m:根据sys结构体建立主问题和子问题的YALMIP模型;solve_master.m:构建主问题并求解,返回整数解和LB;solve_sub.m:固定整数解后求解子问题,返回运行成本和乘子;build_cut.m:根据乘子生成割平面约束并更新cut_list;verify_solution.m:对最终配置做完整MILP验算,输出对比报告。
这种分文件写法有个好处,排查问题时可以直接在build_cut.m里打印一条新割表达式,单独分析它的系数是否合理。
如果只是想先跑通GBD再研究细节,用一个单一的gbd_single_file.m把所有函数写成local function也能跑,但后面增加多场景、多时段扩展时就会很痛苦,我不推荐。
5.3 收敛判据的合理设置与停止条件
GBD迭代什么时候停,我用的标准是:
abs(UB - LB) / abs(UB) <= 1e-4这里有个小讲究:用相对gap而不是绝对gap。当整个项目成本达到干万元量级时,绝对gap设成100元没意义,过了半天还在迭代;设成100000元又太大,可能解还没收敛就停了。相对gap能够自适应规模变化。
同时要设置迭代上限max_iter,我一般设50。如果超过50轮还没收敛,程序打印一条warning退出,提醒检查可行性割是否正确生成。因为正常IES规划算例,GBD在20轮左右就已经能够达到1e-4的相对gap,超过50轮基本可以判定代码有逻辑问题。
还有一个容易被忽略的停止条件:主问题最优解z_k与上一轮解完全一致。如果连续两轮得到的整数配置相同,说明割平面已经不再改变主问题的选型方向,这时候继续迭代只能修改eta的估计值,对整个规划决策没有意义,可以直接提前终止。
6. 实操中的扩展方向
代码稳定之后,后续要做的事情其实还很多。我自己在项目里做过两个比较实用的扩展,一并写在这里。
第一个扩展是多场景随机规划。IES运行受光伏出力、负荷波动、能源价格影响,单一典型日的规划结果往往不可靠。把这套GBD框架扩展为两阶段随机规划,子问题变成多个典型场景下的并行LP,割平面的构造方式基本不变,只是拉格朗日乘子需要按场景聚合。这个扩展在Matlab里实现不算难,我当时的做法是把子问题的求解过程做成一个for循环,逐个场景求解,再按概率加权生成割平面。加完场景之后GBD的收敛速度会比单场景慢,我的经验是设max_iter为100以上,分布式并行求解器可以显著提升子问题求解速度。
第二个扩展是多目标优化。综合能源系统规划一般不只是看经济成本,还要考虑碳排放。GBD处理多目标稍微绕一下,我用的是加权法或epsilon约束法,把碳排目标转成一个带右端项的约束,然后在外层循环扫描权重,内层每个权重下跑一次GBD。这样写起来最省事,也不破坏GBD的收敛性质。
如果你做的是区域综合能源系统,需要考虑电网交互和热网网损,子问题会变成更复杂的LP甚至QP,我的建议是优先尝试用YALMIP自带的指数锥或二次型建模工具处理,不要让子问题出现整数变量。子问题只要保持连续凸,GBD的收敛保证就还在。一旦子问题加入任何整数变量(比如机组最小启停时间的0-1约束),那么子问题本身变成MILP,子问题对偶信息不再有效,整个GBD框架就需要大改。这就是我反复强调子问题必须保持LP的根本原因。
在实际项目开发过程中,我最大的体会是:GBD的算法本身并不难,难的是建模阶段有没有把问题的结构想清楚。如果原问题本来就适合分解,代码写起来格外顺;如果原问题存在强耦合的非凸项,非要套GBD就会到处碰壁。所以别急着写代码,先在纸上反复确认原问题固定整数变量之后是否真的落成一个漂亮的连续优化问题。这一步确认好了,后面的一切都是水到渠成。