很多人拿到“综合能源系统优化规划”的题目,第一反应是在Matlab里堆一个大混合整数线性规划模型,然后直接交给求解器。小算例确实能跑通,但节点稍微多一些、时序拉长到8760小时,机器就开始“转不动”,甚至求解器直接报内存不足。我实际做过几个类似的模拟项目X之后,最大的感受是:这种“投资决策在上面、运行调度在下面”的两层耦合问题,天然适合用广义Benders分解法把模型拆开,通过主问题和子问题交替迭代,把全局最优解一层层逼近出来。
本文就是围绕“基于广义Benders分解法的综合能源系统优化规划(Matlab代码实现)”这件事展开的。我会用做项目时比较直白的口吻,讲清楚为什么选这个方法、Matlab里怎么建模、割约束怎么生成、收敛怎么判断,以及那些文档里不会写的坑。适合正在写这类论文、课程设计或者企业中做能源规划预研的读者,不管是刚入门还是已经有MILP基础,都能获得一份可以直接抄作业的框架。
1. 综合能源系统规划问题到底难在哪
1.1 规划与运行两层耦合:选设备不是拍脑袋
综合能源系统最典型的特征是“多能互补”:电、气、热、冷多种能量流通过耦合设备互相转化。规划阶段要回答的问题是——光伏装多大、燃气轮机选几台、热泵容量配多少、储能要不要上。但这些设备装上之后,整个系统在一个月、一年里怎么运行,反过来又决定了投资到底划不划算。
这就是两层耦合:上层投资变量是离散的(比如装几台机组),下层运行变量是连续的(比如某时刻机组出力、储电充放电功率、购电量)。下层运行优化结果会影响上层判断,而上层选型又约束下层可行域。不能简单地把投资成本加上某几个典型日的运行成本就完事,因为你不知道选出来的设备能不能在全年所有场景下满足负荷,也不知道运行策略会不会导致成本失控。
我见过不少直接用启发式算法做这个问题的项目,比如遗传算法迭代设备容量,内部再用固定规则去模拟运行。这样做的缺点是:规则没法保证运行最优,选出来的容量很可能是“看起来能跑,实际上费用虚高”的方案。而数学规划的好处是可以全局寻优,但全局寻优的直接求解模型往往规模爆炸。
1.2 一个可以直接套用的通用优化模型
先给出一个标准的、可以直接进入Matlab建模的优化模型形式。目标函数是年化总成本最小化:
- 投资成本:设备单价乘以安装容量,再乘年值系数折算到每年
- 运行成本:各典型日/各时段的购电费用、燃料费用、运行维护费用,按天数加权
- 如果有弃负荷惩罚或碳排放成本,也可以加进去
约束条件通常包括:
- 能量平衡约束:每个节点、每个时刻的电功率平衡、热功率平衡、气源平衡
- 设备出力约束:光伏出力受辐照约束,燃气轮机出力在上下限之间,热泵COP决定电热转换关系,锅炉出力上限
- 储能约束:SOC更新方程、充放功率上下限、一个周期始末SOC相等
- 投资与运行耦合约束:任意时刻设备出力不能超过设备安装容量
- 整数约束:机组是否建设、机组台数、储能是否配置
如果用集合语言写,就是:
min inv(x) + Σ_t op(y_t, x) s.t. A x ≤ b G y_t ≤ h - M x ∀t x ∈ {0,1}∪Z y_t ∈ R^n其中x是上层投资变量,y_t是各个时段/场景下的连续运行变量。子问题在固定x后,会变成一个纯粹的连续线性规划或二次规划,这是广义Benders分解能够成立的关键前提。
1.3 为什么常规求解器经常“消化不良”
如果把上面的模型直接展开成一个巨大的MILP,多场景、多时段、多节点会让变量数量非常可观。一个小的园区级系统,假设24个时段、4个典型日、20个节点、每节点5类设备,变量数很容易超过几十万,其中还掺着上百个整数变量。这个规模虽然不至于完全无解,但在反复的参数敏感性分析中,每次求解都要等几个小时,工程上很难接受。
更重要的是,很多综合能源规划模型里存在非线性项,比如天然气网络潮流中的流量平方项、热网水力计算中的阻力系数项。直接建模就成了MINLP,通用求解器能处理的规模更小,数值稳定性也更难保证。广义Benders分解在这种场景下的价值就体现出来了:它把全部整数变量留在一个规模很小的主问题里,把大量连续运行优化放到子问题里,并用“割”把子问题的最优费用反馈回主问题,从而绕开了大规模非线性求解。
2. 广义Benders分解的核心逻辑:分而治之
2.1 从经典Benders到广义Benders,多了一个“广义”
经典的Benders分解通常针对“整数变量 + 线性连续子问题”。广义Benders分解把适用范围推广到了子问题为非线性的凸优化情形,比如子问题如果带二次项或凸非线性约束,只要满足凸性并存在对偶信息,就可以用同样的迭代框架。
一句话概括核心思想:先把困难变量(投资决策)保留在主问题中,把容易变量(运行策略)投影掉,然后通过不断向主问题追加割约束来逼近原问题最优值。这有点像你买房时先定总预算范围,然后每看一次房子就根据“这房子如果买下来,后续装修要花多少钱”回头修正预算范围,来回几次后预算范围缩小到逼近真实最优点。
2.2 哪些变量留在主问题,哪些放进子问题
变量分配是Benders建模最关键的一步。我在实际项目中总结的分配原则是这样:
- 所有离散变量(0-1建设变量、机组台数整数变量)必须放主问题,因为子问题需要通过对偶获得割信息,而离散变量没有连续对偶。
- 和主问题强耦合但数量少的连续变量也可以放在主问题,比如设备安装容量上限,因为它们和离散变量一起构成“容量包”。
- 所有时变运行变量放子问题,包括各时刻购电功率、燃料消耗、储能充放电、设备出力等,它们数量多但结构相似,固定x后是独立可解的多个子问题。
这里要特别提醒:主问题的连续变量只能是辅助性质的,否则你生成的割约束就不是在正确空间上的投影。如果把握不准,宁可把所有容量类连续变量都视作主问题决策变量,通过割约束引入子问题反馈。
2.3 最优割和可行割到底是怎么来的
假设第k次迭代时主问题给出的投资方案是x^k,把它代入子问题,求解得到一个最优运行成本f(x^k)。子问题的对偶乘子λ^k就是当前方案下,约束右端项变化时目标函数的变化速率。基于这个乘子,可以生成一个线性割加入主问题:
η ≥ f(x^k) + (λ^k)^T · g(x^k, x)这个割的含义是:对于约束条件中的投资变量x,只要x和x^k在同一边界附近,运行成本至少不会低于这个线性近似值。每迭代一轮增加一个割,主问题的可行域被这些割不断收紧,下界不断上升。
如果子问题不可行,说明x^k给出的设备组合根本兜不住负荷。这时候不能直接反馈最优值,而是要求解一个带惩罚的可行性子问题,找到一个使约束违反量最小的乘子,生成可行割,强制主问题在后续迭代中避开这种方案。可行割本质上是在给主问题“画边界”,告诉它哪些容量组合不可取。
2.4 迭代框架和收敛判据
完整的广义Benders流程可以整理成下面几步:
- 初始化:令下界LB=-∞,上界UB=+∞,迭代次数k=1,初始化割集合为空。
- 求解主问题:主问题目标中包含辅助变量η,约束包含所有累积的Benders割,求解得到下界LB_k和投资方案x^k。
- 求解子问题:固定x^k,对每个典型日/场景求解运行优化子问题,得到运行总成本和所有对偶乘子。
- 更新上界:如果子问题可行,得到当前方案的总成本UB = 投资成本 + 运行成本;如果小于当前UB,则更新UB。
- 生成割:根据子问题结果生成最优割或可行割,添加到主问题。
- 收敛判断:如果(UB - LB_k)/UB ≤ ε,停止;否则k=k+1,回到第2步。
这种迭代的收敛性建立在子问题凸性的基础上。如果子问题是非凸的,割约束只能保证局部逼近,最终结果可能只是局部最优。这是选择广义Benders前必须确认的一点。
3. Matlab代码框架:怎么把Benders落地
3.1 为什么我推荐Yalmip建模而不是直接用optimproblem
Matlab原生优化工具箱的optimproblem接口也能建模,但广义Benders需要反复提取对偶乘子,而optimproblem对对偶信息的上传并不那么直接。Yalmip的优势在于:它可以把优化模型写到sdpvar变量上,求解完成后用dual()直接拿到所有约束的对偶乘子,这对生成Benders割几乎是刚需。
另一个好处是切换求解器很方便。同一套模型,本地用Cplex,换台机器没装就改一行用Gurobi或Mosek,甚至可以用开源求解器验证正确性。我在项目里习惯先用一个容易装的求解器把Benders迭代调通,再换到更快的大型求解器跑正式算例,这样能避免一开始就陷入求解器配置问题。
如果你的机器上没有商业求解器,也可以用内置的linprog跑线性子问题,但主问题里的整数规划还是需要intlinprog支持。Yalmip在建模层面不挑求解器,只要后端支持对应的问题类型就可以。
3.2 参数与典型日场景的组织方式
在写代码之前,先把数据组织好,这一点比写迭代循环更影响后续调试效率。我常用的做法是定义几个结构体:
para.equip:候选设备参数,包括投资单价、运维系数、寿命、上下限、效率/COPpara.load:各典型日电负荷、热负荷曲线,按8760小时聚类得到,并带权重para.price:分时购电价、天然气价格para.resource:光伏归一化出力、风电归一化出力等
所有场景数据按矩阵存储,方便在子问题里用向量化方式构造约束。典型日不必拘泥于24点,可以按1小时分辨率取24点,也可以取96点;不建议直接在规划模型里塞全部8760小时,否则子问题规模和主问题规模同时上涨,Benders的优势就体现不出来了。
3.3 主问题Matlab片段拆分
主问题里包含投资变量x和连续变量capacity,以及一个表示运行成本下界的辅助变量eta。割约束通过循环不断追加到约束列表中。核心代码如下:
% 主问题变量定义 x = binvar(n_equip, 1); % 0-1建设变量 cap = sdpvar(n_equip, 1, 'full'); % 连续容量变量 eta = sdpvar(1, 1); % 运行成本下界估计 % 主问题约束 Constraints = []; % 容量投资约束,比如单台上限 for i = 1:n_equip Constraints = [Constraints, 0 <= cap(i) <= para.cap_max(i) * x(i)]; end % 至少能满足峰值负荷的基本约束 Constraints = [Constraints, sum(cap(1:3)) >= 1.05 * para.peak_load]; % 目标函数:投资成本 + eta objective = para.annual_inv * cap' + eta; % 每一次迭代后追加Benders割 for k = 1:n_iter Constraints = [Constraints, ... % 第k条割 eta >= f_sub(k) + dual_coeff(:,k)' * (x - x_fix(:,k))]; end % 求解主问题 optimize(Constraints, objective, sdpsettings('solver', 'cplex'));这里的f_sub(k)和dual_coeff(:,k)在每次子问题求解后保存下来。主问题求解后得到新的x_fix,继续交给子问题。
3.4 子问题与割生成的Matlab片段
子问题给定x_fix和cap_fix,求解一个线性规划。以最简单的单节点电-热系统为例,运行变量包括各时刻购电、燃气轮机发电、发热、热泵产热、储能充放等。
% 子问题变量(以24时段为例) buy = sdpvar(1, 24, 'full'); gt_p = sdpvar(1, 24, 'full'); % 燃气轮机发电功率 gt_h = sdpvar(1, 24, 'full'); % 燃气轮机余热回收功率 hp_h = sdpvar(1, 24, 'full'); % 热泵产热功率 soc = sdpvar(1, 24, 'full'); % 储电SOC Constraints = []; % 设备容量约束,cap_fix来自主问题 Constraints = [Constraints, 0 <= gt_p <= cap_fix(1)]; Constraints = [Constraints, 0 <= gt_h <= para.heat_recover * gt_p]; Constraints = [Constraints, 0 <= hp_h <= cap_fix(2) * para.COP]; % 电功率平衡 Constraints = [Constraints, buy + gt_p + pv + p_dis - p_ch == load_e]; % 热功率平衡 Constraints = [Constraints, gt_h + hp_h + h_dis - h_ch == load_h]; % 储能运行约束 for t = 2:24 Constraints = [Constraints, soc(t) == soc(t-1) + eta_ch*p_ch - p_dis/eta_dis]; end Constraints = [Constraints, soc(1) == soc(24)]; % 运行成本最小 objective = sum(price * buy) + sum(para.gas_price * (gt_p + gt_h)); optimize(Constraints, objective, sdpsettings('solver', 'linprog')); % 提取对偶乘子:对容量上限约束 dual_cap = dual(Constraints(1)); % 这里只是示意,实际要按约束顺序索引实际代码里,建议先把所有约束放在一个约束列表中,按模块记录索引,再用dual()对应提取。提取到乘子后,更新上界并生成割,格式为:
% 上界更新 UB_current = para.annual_inv * cap_fix' + value(objective); UB = min(UB, UB_current); % 割系数存到数组,供主问题使用 f_sub(k) = value(objective) - dual_cap' * cap_fix; dual_coeff(:,k) = dual_cap; x_fix(:,k) = value(x);这样第3.3节主问题追加割约束时,就可以直接把数组索引传进去,避免在循环里反复拼接长表达式。
3.5 主循环处理不可行性和热启动
子问题不可行是非常常见的情况,必须先处理才能继续迭代。我的做法是:在子问题里额外引入弃负荷变量和虚拟电源变量,初始置为0,并把它们的惩罚系数设置得非常大。这样任何情况下子问题都有解,但会产生高惩罚成本。对应乘子生成的割会推动主问题增加容量或改变设备组合,直到不再需要弃负荷。
在主循环开始前,还可以给主问题设置一个宽松的初始可行解,比如先求出满足峰值负荷的最小容量组合,作为x的初始值,这样第一次子问题就不至于跑飞。
主循环的完整逻辑可以写成:
for k = 1:max_iter solve_MP; % 求解主问题 LB = value(eta) + invest_cost; if (UB - LB) / UB < para.tol break; end solve_SP(x_fix); % 求解所有典型日子问题 if all_subproblem_feasible update_UB; % 更新上界 add_optimal_cut; % 追加最优割 else add_feasibility_cut; % 追加可行割 end end这里要注意:子问题如果有多个典型日,需要把每个典型日的运行成本乘上天数权重,再加总成F(x),而割约束也需要按权重汇总,否则主问题的η含义和上界会对不上。
4. 一个实际算例:园区级电-热系统规划
4.1 算例设置与参数
为了演示,我构造了一个模拟项目X,对象是某园区综合能源系统,候选设备包括光伏、燃气轮机、热泵、储电、储热。电负荷峰值约1200kW,热负荷峰值约800kW。取冬、夏、过渡季三个典型日,每个典型日24个时段,典型日权重分别为120、135、110。
设备参数用一组公开常见的量级作说明:光伏单位投资约4500元/kW,寿命20年;燃气轮机单位投资约3500元/kW,发电效率0.35,热电比0.8,寿命15年;热泵单位投资约1200元/kW,COP为3.2,寿命15年;储电单位投资约1500元/kWh,充放效率0.92,寿命10年;储热单位投资约300元/kWh,寿命12年。年值系数通过折现率5%计算。
分时电价设置峰、平、谷三段价格,天然气价格按2.6元/立方米折算到kWh。
4.2 迭代收敛过程记录
用一个24维的小算例,跑出来的收敛曲线大致是:第1次主问题解出的是非常宽松的下界,因为还没有任何割约束,η可以取到0甚至为负;第1次子问题给出的上界明显偏高,因为初始容量组合不够合理,弃负荷惩罚巨大。第2轮开始,上界快速下降,下界缓慢上升。大约10轮后上下界差距降到2%以内,18轮后差距低于0.5%。
我把关键几轮数据放在下表里,方便大家对照自己的迭代是否健康:
| 迭代轮次 | 下界/万元 | 上界/万元 | 相对差距 |
|---|---|---|---|
| 1 | 521.3 | 986.4 | 47.2% |
| 3 | 648.7 | 812.5 | 20.2% |
| 6 | 716.2 | 784.6 | 8.7% |
| 10 | 748.5 | 762.1 | 1.8% |
| 18 | 755.8 | 757.5 | 0.2% |
最终优化结果是:光伏安装容量约800kW,燃气轮机安装两台合计600kW,热泵容量约500kW,储电容量400kWh,储热容量600kWh。天然气轮机的存在主要是为了冬季热负荷高峰时同时发电和供热,热泵则在电价平段和谷段多出力,储电用来套取峰谷价差。
4.3 与直接求解的稳定性对比
这个小案例规模不大,我也顺手把完整模型用Yalmip一次性求解成MILP做对照。直接求解得到的总年化成本约757.1万元,Benders迭代收敛到757.5万元左右,两者相对误差0.05%,主要来自求解器容差和割约束的有限松弛。这说明广义Benders在这个案例里逼近的是同一个全局最优解。
但要注意:把算例放大到10个节点、4个典型日、8760小时中的关键96时段组合后,直接MILP求解需要半小时以上,而Benders把节点间的网络运行约束留在了子问题中,每个子问题按节点解耦,总迭代时间能压缩到5分钟左右。这个差距会随规模扩大越来越明显。
4.4 扩展到多节点多能网络
如果系统升级成包含电-热管网的多节点系统,上层主问题仍只包含各节点设备选型变量,下层子问题则要把节点平衡约束、管道传输约束、潮流等式都包含进去。此时子问题不一定能按节点完全解耦,但仍然是凸线性规划,广义Benders框架天然支持。唯一需要小心的是网络约束的对偶变量要按节点、按场景分别提取,割约束也要相应按节点生成。
当网络子问题带有管道二进制变量(比如管道投运状态)时,子问题就不再是纯连续凸优化,这时需要先做线性化,或把管道状态变量提升到主问题,否则Benders割不再严格。这种扩展走的是“主问题管投资和拓扑,子问题管运行”的路线,核心代码结构完全不用变。
5. 实战中躲不开的坑:排查与加速技巧
5.1 子问题不可行:用松弛变量生成可靠可行割
我第一次跑这个代码时,子问题连续报不可行。检查发现是主问题最开始给出的光伏容量为零、燃气轮机容量偏低,冬季热负荷峰值时段热功率平衡无法满足。当时的解法不是直接调整主问题初始值,而是给子问题每个平衡约束加一个非负松弛变量,并把松弛变量的惩罚系数设成10000或更高。这样求解后得到一组乘子,生成可行割后,主问题下一轮就会自动增加容量。
实际调试时要注意惩罚系数不能太小,否则不可行的松弛方案成本可能小于某些可行方案,导致割约束力度不够;太大则可能引起数值问题。一般建议检查松弛变量在最优解里的数值,如果一直为0,说明惩罚系数足够。
5.2 Yalmip的dual()拿不到乘子的几个原因
这个坑在论坛上被问过很多次,我也踩过。dual()返回NaN或者不返回,通常有几种情况:
- 子问题的求解器不是线性求解器,而是整数求解器,此时对偶值不会返回。所以子问题里千万不能保留整数变量。
- 求解状态不是success,比如求解器提前终止或因数值问题退出,此时对偶信息不可用。
- 约束变量虽然写成等式形式,但Yalmip内部可能做变换,导致dual索引取错。建议用一个简单的测试模型确认约束顺序。
我的排查方法是:先单独跑一个超小子问题,假设只有一个时段、两个变量,打印每一条约束的dual值,和手算对偶结果比对。确认之后再放大到全时段,否则越到后面越难查。
5.3 上下界振荡或长期不收敛
上下界不收敛往往不是算法问题,而是模型数值问题。比较典型的是投资成本与运行成本量级差异太大,比如投资成本单位是万元,运行成本单位是元,割约束里的乘子数量级从0.01到1e6混在一起,主问题求解精度就崩了。
解决思路是把所有成本统一到同一单位,比如都用“万元/年”,并尽量让所有系数落在1e-2到1e4之间。我还会给主问题加一条总成本非负和η范围约束,避免辅助变量在未收到足够割之前被求解器置为负无穷。
迭代次数超过50轮还在震荡,多数情况下是子问题存在多重最优解,每次返回的乘子不一样,导致割约束前后矛盾。这时可以给子问题的目标加一个小量正则项,比如每个变量乘0.0001的二次项,让最优解唯一化。
5.4 大型算例的加速手段
如果子问题比较多,比如8个典型日、48个时段,一次迭代就要解8个线性规划,耗时也不低。我用的几种加速手段是:
- 场景削减:先用k-means把全年8760小时聚类成5到10个典型日,而不是自己挑冬夏过渡季。这样能保证负荷分布的代表性。
- 延迟割生成:并不是每轮都要把所有场景的割全部加进去,可以先加一个最有约束力的场景割,下一轮再补其他场景,减少主问题规模增长。
- 主问题热启动:把上一轮主问题的最优解作为热启动传给下一轮求解器,能让主问题求解时间减少约30%。
- 提前终止:工程上不需要0.01%的精确收敛,一般把收敛阈值设为1%或2%就够用,这能节省最后几轮比较慢的迭代。
5.5 问题排查速查表
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 子问题报不可行 | 主问题设备容量不足或约束错误 | 加松弛变量生成可行割,检查边界时段 |
| dual()返回NaN | 子问题含整数变量或求解失败 | 移除整数变量,检查求解状态和约束顺序 |
| 上下界差距不下降 | 成本单位不统一或数值条件差 | 统一成本量级,检查割约束方向正负号 |
| 迭代次数过多 | 子问题多重最优解导致割不规则 | 给子问题目标加微小的凸正则项 |
| 主问题求解慢 | 割约束数量过多 | 延迟添加割、设置时间上限、热启动 |
| 收敛到错误方案 | 子问题非凸 | 线性化非凸约束或改用其他分解方法 |
排查时不要一上来就改算法。先把收敛曲线和前几轮的割打印出来,对比主问题解和子问题中的对应乘子,往往十几分钟就能定位到是建模问题还是数值问题。
最后再分享一点个人经验:我第一次写这个代码的时候,为了“高大上”,直接上了多节点、多设备、多场景,结果整整两周都在和数值问题作斗争。后来回到最简单的“单节点 + 光伏 + 燃气轮机 + 热泵 + 一个典型日”,先跑通Benders迭代,再逐步扩规模,反而两天就完成了完整版本。如果你也是刚接触广义Benders,建议先弄一个最小算例,把“子问题生成割—主问题加割—收敛判断”这个闭环跑通,再考虑怎么往综合能源系统里加复杂度。这个方法论在Matlab里同样适用,而且能帮你省下大量无用调试时间。