广义Benders分解法这几年在综合能源系统规划里确实被反复提起,相关论文也是一抓一大把。但说句实话,很多文章把重点放在了理论推导上,真正敢把Matlab代码完整放出来的不多,能跑通、能复现、能改着用的更少。我去年因为课题需要,用广义Benders分解法做了一整套园区级综合能源系统的优化规划,前前后后折腾了将近两个月,踩了不少坑,也积累了一些从论文到代码落地的实战经验。这篇博文就把整个思路、建模、算法流程、Matlab实现细节、算例调试过程一次性说清楚,给正在做相关方向或者需要复现GBD代码的朋友做个参考。
这个项目的核心其实就三件事:一是把一个带整数变量的综合能源系统规划问题建出来,二是用广义Benders分解把混合整数非线性规划拆成主问题和子问题迭代求解,三是用Matlab把整个流程写出来并跑通。如果你手里有Yalmip工具箱,再搭配Gurobi或者Cplex,照着这个思路可以实现一套自己的求解框架,代码量不大,但里面的坑不少,我会把关键的地方都点出来。
1. 项目到底在解决什么问题:从规划难题说起
1.1 综合能源系统规划里的"混合整数"之痛
综合能源系统优化规划,本质上是一个在满足负荷需求的前提下,确定设备类型、设备容量、网络拓扑等长期决策变量的问题。你既要把燃气轮机、电锅炉、储能、光伏这些设备的"装不装、装多大"定下来,又要模拟系统在典型日场景下的运行策略,看看装完之后够不够用、经济不经济。
这里最麻烦的地方在于,设备选型和容量一旦引入"选"这个动作,就必然出现0-1整数变量。比如某台燃气轮机候选容量有三个档位,选哪个档位不能用连续变量平滑表示,只能用整数变量来判断。规划问题的运行模拟部分又带着大量连续变量和时序约束,比如储能充放电的SOC递推、燃气轮机的爬坡约束、电热功率平衡等。于是整个问题变成了混合整数规划,规模一上去,直接用Gurobi硬刚,分支定界法会非常吃力。
我刚开始做过一个中等规模的园区算例,节点数大概20个左右,候选设备二三十台,典型日取四个季节各取3天,每天24个时段。直接把整个MIP丢给求解器,有时候跑六七个小时都不收敛,内存占用还特别大。这时候就需要分解算法登场,把大问题拆成若干小问题,各自单独求解,再通过迭代让子问题的解逐步逼近全局最优解。
1.2 为什么选广义Benders分解而不是直接硬解
广义Benders分解的适用场景非常明确:问题中存在两类变量,一类是"复杂变量"或"整数变量",另一类是"简单变量"或"连续变量"。当整数变量一旦固定下来,剩下的子问题就是一个连续优化问题,求解难度大幅下降。
这个思路天然适合综合能源系统规划。上层决定设备的选型和容量,下层在给定设备配置的前提下,做典型日运行优化。两个问题之间通过"Benders割"来传递信息——子问题会告诉主问题"你上一次给的配置方案,因为运行成本或者可行性原因,不够好,应该往哪个方向修正"。主问题拿到反馈后更新整数解,继续下一轮迭代。
跟直接求解相比,GBD有几点实实在在的好处。第一,每次迭代都只解一个规模小得多的MIP和一个或者多个LP,对求解器非常友好;第二,如果你有现成的MILP求解器(比如Gurobi、Cplex)和LP求解器,组合起来非常灵活;第三,GBD天然保留了原问题的物理结构,每个子问题对应一个典型日的运行优化,代码易读性高,调试也方便。当然它也有短板,比如在某些情况下需要加入可行性割,或者收敛速度较慢,这些在后面会展开讲。
2. 数学模型:先把规划问题写清楚再谈算法
2.1 系统结构与典型场景设定
在写代码之前,必须把数学模型定义清楚。我这里以一片包含风、光、气、储、热泵等多种能源设备的园区综合能源系统为例。系统内部通过电母线和热母线连接各设备,外部电网可以购电,天然气网可以购气,能量在这里进行转化、存储和分配。规划目标是确定各设备的安装容量,运行模拟则放在典型日上做。
典型日的选取很重要。如果直接把全年8760小时全部纳入规划,时间维度过大,模型求解难度非常高。常见做法是用聚类方法选出若干个典型日,比如春夏秋冬各选一个或几个代表日,每个典型日赋一个权重,代表该季节在全年的时间占比。我自己采用的是k-means聚类,从全年数据里聚出12个典型日(每个选3天代表一个季节场景),效率和精度之间平衡得还可以。
系统结构这块,我建议先在纸上把能量流图画清楚:哪些设备消耗什么能源、产出什么能源、通过哪个母线耦合、储能怎么充放,这些直接决定了约束方程怎么写。画不清楚就开始写代码,后面改模型的时候会非常痛苦。
2.2 目标函数、决策变量与约束条件
目标函数按年费用最小来写,这是目前综合能源系统规划用得最多的目标:
- 设备投资等年值:设备单位容量投资成本乘以安装容量,再乘以资本回收系数(CRF)折算成年值;
- 年运行费用:包括购电费用、购气费用、设备运维费用;
- 碳排放成本:如果有碳排放约束,可以在目标里加入碳税,或者作为约束条件限制年排放量。
决策变量分两层。第一层是规划变量,主要包括各候选设备的安装容量和0-1安装状态变量;第二层是运行变量,主要包括各设备在典型日各时段的出力、储能充放电功率与SOC状态、与外部电网交互功率、弃风弃光电量等。
约束条件主要包括:电功率平衡约束(各时段内发电加购电加储能放电等于电负荷加电锅炉耗电加储能充电)、热功率平衡约束、各设备出力上下限约束、爬坡约束、储能SOC递推及容量约束、设备安装容量约束(安装容量必须在候选离散集中取值,或者不超过上限),以及碳排放总量上限约束等。
模型写出来之后,规模大概是这样的:12个典型日乘以24小时,每个时段几十个运行变量,再加上二三十个整数变量。如果不分解,直接丢给求解器就是一个大MIP,计算压力确实大。这也就是后面要用GBD做分解的动机所在。
3. 广义Benders分解算法:原理拆解与流程设计
3.1 将原问题投影到整数变量空间
广义Benders分解的起点是把原问题里的变量分成两组:复杂变量(一般指整数变量)和简单变量(指连续运行变量)。原规划问题可以抽象写成:
min F(x, y) = c^T x + d^T y
s.t. A x + B y ≥ b
x ∈ X(整数可行域),y ≥ 0
GBD的核心思想是:把问题投影到x变量空间里。对于任意给定的x,剩下的关于y的子问题是一个线性规划或连续凸优化问题。主问题则是一个只含x变量的整数规划,目标函数里用一个额外变量alpha来逼近子问题的最优值。
主问题和子问题之间不断交换信息:主问题给出一个x的候选解,传给子问题;子问题求解得到y的最优值和最优拉格朗日乘子,然后基于对偶解生成一条Benders割,加回到主问题中;主问题再解一次,得到新的x。如此循环,直到主问题的下界和子问题的上界之差小于设定阈值。
3.2 最优割与可行性割是怎么生成的
子问题求解之后可能出现两种情况:第一种是给定x后子问题可行,此时可以正常求出运行成本最优值,同时通过KKT条件或对偶解得到Benders最优割;第二种是给定x后子问题不可行,这通常意味着设备容量配置过小,无法满足负荷需求,此时需要解一个松弛子问题(通常是加松弛变量的可行性问题),并生成Benders可行性割。
这里有一个关键的经验:可行性割的处理方式会显著影响收敛速度。很多人第一次写GBD,只加了最优割,结果迭代几十轮还不收敛,就是因为忽略了不可行情况下的可行性割。我在实现中对不可行子问题采用了增加非负松弛变量的方式,目标是最小化松弛量之和,然后取对偶乘子生成割。这一下收敛速度提升非常明显。
迭代终止条件我用了两个,一个是主问题目标值(下界)与子问题目标值(上界)之间的相对误差小于0.5%,另一个是相邻两次迭代主问题整数解的差异足够小。实际跑下来的经验是,0.5%的gap已经能让规划结果稳定在合理区间了,强行把gap压到0.1%以下,迭代轮数会增多好几轮,但结果差异其实很小,性价比不高。
3.3 多少轮迭代能收敛:我的实测数据
以我那个算例来说,12个典型日、26个候选设备、每个设备有若干离散容量档位的场景,程序从初始解开始迭代,大概在9到14轮之间达到0.5%的相对gap。前几轮下降非常快,第一轮上界可能在3000多万,第二轮就能掉到1200万左右,后面几轮主要是震荡修正,到第9轮左右基本稳定在1150万上下。
如果只加最优割不加可行性割,同样的算例有时候会拖到40多轮还不停,而且主问题给出的容量方案在子问题里经常不可行,就是因为在迭代初期那些偏小的容量方案没有通过可行性割被及时"纠正"回来。所以这条经验一定记住:可行性割不是可选项,是必选项。
4. Matlab代码实现:从框架到细节逐层拆解
4.1 代码整体框架与文件结构
Matlab代码实现是整个项目里花时间最多、也最容易出差错的部分。我的文件结构大致是这样的:
- main.m:主程序,负责参数初始化、调用求解流程、汇总结果;
- data_input.m:输入数据赋值,包括负荷数据、设备参数、能源价格、典型日权重等;
- master_problem.m:构建主问题MIP模型并调用求解器;
- sub_problem.m:构建子问题LP模型并求解,同时计算Benders割系数;
- add_cut.m:把生成的最优割或可行性割添加到主问题中;
- check_convergence.m:根据上下界gap判断是否迭代终止。
主程序和两个问题的数据交互,我全部通过结构体(struct)来传递,比如model_data存储设备参数,dispatch_data存储时段和典型日信息,cut_data存储累积的Benders割集合。这样做的原因很简单:避免Matlab函数传参时参数列表太长太乱,也方便中途加数据字段。
4.2 主问题怎么建、怎么解
主问题规模不大,变量主要是整数变量和一个表示子问题成本的连续变量alpha。目标函数等于设备投资等年值加上alpha。约束条件除了设备安装容量相关的约束外,还包括每一轮迭代生成的Benders割。
在Matlab里我用Yalmip建模,求解器用Gurobi。主问题的Yalmip代码大概长这样:
x = binvar(n_device, 1); % 0-1安装状态 cap = sdpvar(n_device, 1); % 安装容量 alpha = sdpvar(1, 1); % 子问题成本近似 Constraints = []; % 安装容量约束:状态为0时容量为0,状态为1时容量在候选范围 for i = 1:n_device Constraints = [Constraints, 0 <= cap(i) <= x(i) * cap_max(i)]; end % 已有Benders割 for k = 1:n_cuts Constraints = [Constraints, alpha >= cut_beta(k) + cut_coeff{k}' * [x; cap]]; end Objective = invest_cost' * cap + alpha; ops = sdpsettings('solver', 'gurobi', 'verbose', 0); optimize(Constraints, Objective, ops);这里给alpha设置的约束形式是"alpha大于等于某个仿射函数",是标准Benders割形式,系数来自子问题的对偶解。千万别在这里手滑写成"alpha小于等于",不然整个迭代逻辑就完全反了,上下界根本不会收敛。
4.3 子问题怎么建、怎么解、怎么提取对偶乘子
子问题是LP,可以用Yalmip建模后调Gurobi,也可以用linprog直接解。但为了后面提取对偶乘子方便,我建议还是用Yalmip,因为Yalmip可以很简单地通过dual命令拿到约束对应的拉格朗日乘子。
子问题的核心约束是运行约束。给定主问题的x和cap,构建每时段每典型日的设备出力约束、储能SOC约束、电热平衡约束等。所有等式约束的对偶乘子就是生成Benders割的关键。这里我总结出一个重要的代码技巧:为了保证对偶乘子符号一致,建议把约束全部写成"左边减右边≥0"或者"左边减右边=0"的规范形式,并且在generate cut时严格按Yalmip返回dual变量的原始符号来写割,不要自己脑补一个负号。
我在调试时见过不少次这样的场景:代码跑起来看起来正常,但gap就是不收敛,或者来回震荡,最后发现就是某个约束的等式方向写反了,导致对偶乘子符号出错。这个小坑非常隐蔽,排错极其痛苦,建议大家在一开始写约束时就统一规范。
4.4 列出关键代码片段:子问题与割生成
子问题典型日t、时段h的模型构建和割生成代码片段如下:
function [obj_val, cut_beta, cut_coeff, feasible] = sub_problem(x0, cap0, params, day_weight) % 输入: 主问题给定的安装状态x0和容量cap0 % 输出: 子问题最优值、Benders割系数、可行性标志 y = sdpvar(n_var, 1); % 运行变量 s = sdpvar(n_slack, 1); % 松弛变量(可行性问题) Constraints = []; % 平衡约束与设备约束... % 注意:固定x和cap,通过赋值方式传入 Constraints = [Constraints, A_eq * y == b_eq + b_slack * s]; Constraints = [Constraints, A_ineq * y <= b_ineq]; Constraints = [Constraints, 0 <= s <= 1e6]; Objective = c_run' * y + big_M * sum(s); ops = sdpsettings('solver', 'gurobi', 'verbose', 0); sol = optimize(Constraints, Objective, ops); if sol.problem == 0 feasible = 1; obj_val = value(c_run' * y); % 提取等式约束的对偶乘子,生成最优割 lambda_eq = dual(Constraints(1)); % 割形式: alpha >= obj_val + lambda_eq' * (b_eq(位置相关) - 矩阵*[x;cap]) ... else feasible = 0; % 生成可行性割,一般取对偶乘子乘以残差 end end代码里那个big_M值得说两句。可行性子问题的松弛变量要加一个极大惩罚系数,这个系数如果太小,子问题会倾向于用松弛量"混过去",而不是真正反映约束不满足的严重性;如果太大,又可能造成数值问题。我试过1e6到1e12几个量级,最后取1e8左右效果比较好,既不会导致LP求解器出现数值警告,又能有效驱动可行性割的产生。
5. 算例测试与结果分析:从数据到结论
5.1 算例参数与场景设计
为了验证算法和代码的正确性,我设置了一个中等规模的测试算例。园区年电负荷峰值约5MW,热负荷峰值约3MW,候选设备包括两台燃气轮机(单台容量候选200kW到2000kW,分5个档位)、两台电锅炉、一台吸收式热泵、一套锂电池储能、一套蓄热罐、光伏和风电各一个候选场站。
能源价格方面,采用分时电价,峰段1.1元/kWh,平段0.65元/kWh,谷段0.32元/kWh;天然气价格2.5元/立方米,按热值折算成单位能量成本。碳排放约束设定为年排放量上限15000吨,如果超出就需要减少燃气轮机出力或者加装更多风电光伏。
5.2 迭代收敛过程与规划结果
程序跑完之后,我把每一轮主问题和子问题的目标值记录下来,画了一条收敛曲线。第1轮上界大约2850万元,下界大约1350万元,看上去gap大得吓人,但这非常正常——alpha一开始被松弛得很宽松,主问题给出的容量方案也比较激进。第2轮到第5轮gap快速收窄,到第6轮上界降到1180万元,下界也升到1120万元,gap在5%以内。第9轮之后gap小于0.5%,程序判定收敛,输出最优方案。
最终规划结果是:两台燃气轮机选2000kW档和1200kW档,电锅炉选800kW,热泵选400kW,锂电池容量2MWh/1MW,蓄热罐600kWh,光伏装机1.5MW,风电装机800kW。总投资等年值大概640万元/年,年运行费用约510万元/年,年碳排放量13200吨,满足约束。
这个结果从工程经验上是合理的:燃气轮机承担基础电负荷,储能平抑波动,热泵和电锅炉配合供应热负荷,光伏风电在白天削峰。如果只用连续变量建模不考虑选型,结果大概率会趋向于"什么都装一点",而整数建模才能给出真正符合设备市场实际的方案。
5.3 与直接MIP求解的对比
为了评估GBD的效益,我还用同样的数据和模型直接调用Gurobi求解原问题。结果是:直接求解跑了约2小时15分钟后,gap仍然卡在3.7%左右,求解器报告说内存占用超过12GB;而GBD方法总共12轮迭代,耗时大约18分钟,最终gap稳定在0.5%以内。虽然没有严格的公平对比条件,但在这个规模下,GBD的求解效率优势已经非常明显了。
这个对比给我最大的感触是:MATLAB环境写原型验证、用GBD做初步规划方案筛选,效率是最高的。直接MIP求解更适合小规模场景做结果基准校验,不适合做大规模反复试算。
6. 实操中的常见问题、调试经验与避坑指南
6.1 收敛慢或者不收敛的常见原因
GBD代码跑起来不收敛,第一反应不应该是调算法,而是排查模型和代码有没有问题。根据我的经验,不收敛的原因通常是这几种情况:一是约束方向写反导致对偶乘子符号错误;二是子问题不可行时没有生成可行性割,或者生成了但没有正确加到主问题里;三是主问题里alpha缺失了部分Benders割中的项;四是求解器数值容差设置不当,导致每次迭代的数值有微小漂移,叠加后造成不收敛。
排查手段上,我会在每一轮迭代后打印主问题和子问题的关键变量值,特别是alpha的取值、割的右侧常数和系数向量的变化。如果发现某一轮割系数突然跳变了好几个数量级,基本可以确定是数值问题或约束建模问题。另外,建议把子问题的对偶乘子手动代入割公式验算一遍,这一步能快速筛掉很大一部分低级错误。
6.2 数值缩放问题
综合能源系统里的数值天然存在量级差异:设备容量可能动辄上万kW,投资成本几百万,而Benders割系数可能又是小数值。如果不对数据进行缩放,Yalmip里可能不报错,但求解器内部的容差判断会很痛苦。我在做的时候把所有成本量纲都统一成万元,把功率统一成kW,储能容量统一成kWh,这样整个模型里的数值基本落在0.01到1000的范围内,求解稳定多了。
另外一个容易忽略的数值问题来自松弛变量的惩罚系数。我建议在生成可行性割时,先检查松弛变量的最优解是0还是明显大于0。如果某个松弛变量一直大于0,说明对应的约束确实不可满足,此时生成的可行性割要包含该约束的对偶信息,否则主问题下一轮还是会做同样的错误决定。
6.3 求解器设置心得
如果你跟我一样在Matlab里用Yalmip加Gurobi,有几个求解器参数值得重点调一调。主问题MIP里,Gurobi的MIPGap参数可以设成0.01%(甚至0),因为主问题规模不大,求解很快,没必要在这里牺牲精度。子问题是纯LP,用默认参数就行,但可以开启Method=2(用对偶单纯形法),因为子问题之间结构类似,对偶单纯形法可以利用上一个解作为热启动,速度能快不少。
迭代上限我设的是100轮,超过100轮直接报错退出,防止程序陷入死循环。按我的经验,模型没问题的情况下,几十轮怎么都该收敛了,如果超过40轮还在跑,基本可以肯定是代码或模型有bug,与其让它空转,不如早点停下来查问题。
6.5 从复现到扩展的几点建议
如果你是想在自己的课题里复现这套代码,我建议不要一开始就上大算例。先用一个极简例子把框架跑通:比如3个设备、1个典型日、6个时段,把主问题子问题的每一轮输出都打印出来,跟手算或者小规模直接求解的结果对照。确认框架正确之后,再逐步扩大规模到完整算例。这个思路能帮你省下至少一周的调试时间。
如果项目后续需要做多场景鲁棒规划或者考虑不确定性的随机规划,GBD也有天然的扩展空间。比如把典型日场景替换成蒙特卡洛抽样的大量随机场景,子问题变成多个场景的并行求解,Benders割汇总后传给主问题。这种扩展在框架上几乎不用改动,只需要把子问题循环起来就行,这也算是当时选择GBD框架的一个长远考量。
最后再分享一个我在代码实现过程中的体会:广义Benders分解法在处理综合能源系统规划这类"上层离散选型、下层连续运行"的问题时,确实比直接丢给MIP求解器高效得多。但它的核心难点不在算法本身,而在"怎么把工程问题写成一个可分解的数模"以及"怎么在代码层面保证割的正确性"。这两关过了,后面的求解就是水到渠成的事。希望这篇博文的经验和代码思路对正在做相关方向的朋友有帮助。