广义Benders分解法在综合能源系统优化规划中的Matlab实现
搞综合能源系统优化规划的朋友应该都体会过那种“模型写起来简单算起来要命”的滋味。设备选型、容量配置是典型的0-1整数变量运行调度里又全是连续变量再加上电、气、热、冷多种能源耦合约束模型规模一上来直接丢给求解器往往会卡到怀疑人生。这个项目就是用广义Benders分解法Generalized Benders Decomposition, GBD把“整数变量”和“连续变量”拆开用Matlab把迭代框架搭起来实现综合能源系统的优化规划。适合正在做IES规划、微电网优化、或者研究分解算法的研究生和工程师尤其适合那些已经能用Matlab写线性规划、但被大规模MILP问题折磨过的人。看完这篇文章你能把一个可运行的GBD迭代框架搬到自己项目里并理解每一步背后的数学逻辑。先说清楚一个关键点GBD不是“银弹”它解决的是“存在少量复杂整数变量、大量连续变量”的混合整数规划问题。综合能源系统规划恰好是这种结构——设备是否新建、建多大容量是“主问题”而给定设备配置后的多能流运行优化是“子问题”。把子问题解耦后整个问题的求解负担大幅下降。我下面会从建模、算法原理、Matlab实现到调试经验完整走一遍代码思路可以直接套用到自己的算例上。1. 项目概述与问题建模思路1.1 综合能源系统优化规划为什么难综合能源系统的规划目标通常是最小化全生命周期总成本包括设备投资成本、运行维护成本、购能成本有时还包含碳排放惩罚。决策变量分两层第一层是设备是否投建、容量多大第二层是在给定设备配置下各时段各能源网络的运行方式。这种两层结构天然适合分解算法。难在三点。第一整数变量导致组合爆炸。一个综合能源园区可能有冷热电联供机组CCHP、燃气锅炉、电锅炉、吸收式制冷机、电制冷机、储能装置每个候选设备又有若干容量档位组合数量非常可观穷举根本不现实。第二约束跨越多个能源网络。电力平衡、天然气平衡、热力平衡、冷量平衡不是独立的CCHP同时产生电和热储能装置又能跨时段转移能量这些耦合约束让模型的结构变得很“密”。第三运行调度的时间尺度细。规划变量是年的但运行验证要细化到典型日、甚至小时的出力曲线如果直接用一个大规模MILP覆盖规划和运行变量规模轻易达到几万甚至几十万个。有的朋友会问“既然有商用求解器可以直接解MILP为什么还要折腾GBD”答案是商用求解器的分支定界在问题规模巨大、约束稠密时同样会变慢尤其当整数变量和连续变量数量严重不平衡时。GBD的价值在于把原有的“大而难”问题拆成“小而多”的子问题每个子问题规模小、结构好求解速度快还能利用并行计算进一步加速。另一个现实原因是学术研究和教学场景里很多时候需要提供一种可解释、可复现的算法实现GBD的迭代过程比求解器内部的黑盒分支更直观。1.2 广义Benders分解法为什么适合这个问题Benders分解最早用于混合整数线性规划固定整数变量后剩余问题变成纯线性规划LP利用LP对偶理论生成切割不断逼近原问题最优解。广义Benders分解把它推广到了非线性、凸优化场景。综合能源系统规划问题中如果子问题目标函数和约束是凸的GBD依然适用。这里有一个容易被忽略的点GBD并不要求主问题是一个简单的混合整数线性规划主问题可以包含凸函数约束只要求解器能处理即可。但实际工程中为了方便求解通常把主问题整理成MILP/MINLP用Gurobi、CPLEX、YALMIP或Matlab自带的intlinprog来解。GBD之所以适合IES规划是因为规划阶段的“整数变量”数量虽然不算海量但决策一旦定下来后续的运行优化往往是线性或凸的。例如某台设备的容量和投建状态确定后运行调度问题通常是一个LP或QP可以高效求得对偶乘子。对偶乘子正是生成Benders切割的关键材料。1.3 整体建模框架实际项目中我习惯把模型写成“两层嵌套”的结构。外层主问题决策变量设备投建状态 x0-1变量设备容量 C还有一部分与主问题相关的连续变量比如储能额定容量。目标函数投资成本 Benders近似的运行成本下界。约束设备数量/容量的上下限、逻辑约束、电网/气网购能上限等。内层子问题给定 x、C求解一个包含多时段的运行优化模型。决策变量各时段机组出力、储能充放电功率、购电量、购气量等。约束能量平衡、设备出力上下限、储能SOC递推、爬坡约束等。子问题会返回两种结果最优运行成本和对应的对偶乘子。对偶乘子会组合成Benders最优割或可行割加到主问题里。主问题求解后更新整数变量再传给子问题循环迭代直到上下界收敛。这里我要多说一句建模时不要一上来就搭巨大的“超级模型”。先把主问题和子问题的接口变量确定清楚然后用“一个Matlab struct 多个函数”的方式组织代码后面调试会省很多力气。2. 广义Benders分解法核心原理与算法步骤2.1 从Benders到广义Benders经典Benders分解处理的问题形式一般是min c^T x f(y)s.t. A x B y ≥ bx 为整数变量y 为连续变量固定 x x_k 后求解关于 y 的子问题。子问题的对偶最优解给出 Benders 割η ≥ f(y_k) λ_k^T (b - A x)其中 λ_k 是子问题对偶乘子η 是主问题中用来逼近运行成本的一个连续辅助变量。每次迭代添加一条割主问题会越来越接近原问题。广义Benders的关键扩展是子问题不一定要是线性规划可以是凸非线性规划只要能够获得拉格朗日乘子或次梯度即可。在IES规划中子问题通常还是LP所以我们用的其实是“经典Benders”的核心思想但处理方法与广义Benders完全一致。这个项目叫“广义Benders”更多是因为它具备处理凸非线性子问题的潜力比如设备效率随负载率变化的情况。2.2 主问题与子问题的构造主问题形式第 k 次迭代min C_inv(x) ηs.t. x ∈ {0,1}η ≥ 运行成本下界由Benders割约束限定可行割约束如果子问题不可行其他主问题约束初始时可以没有Benders割只求解投资费用最小化这会给一个过低的运行成本估计。子问题形式给定 x_kmin C_op(y)s.t. G(x_k, y) ≤ 0H(x_k, y) 0y ≥ 0其中 G 是不等式约束设备出力上限、储能容量约束H 是等式约束能量平衡、SOC递推。注意x_k 是主问题求出的固定值不是变量。这样子问题就是一个纯LP/QP。如果子问题可行得到目标值 f_k 和对偶乘子如果不可行需要求解一个“可行性子问题”通常是松弛约束后的LP得到可行性割。2.3 可行割与最优割的生成这一步是GBD实现中最容易出错的地方我把公式写清楚。假设原问题写成min c^T x d^T ys.t. A x B y ≥ bx ∈ X离散集合y ≥ 0固定 x_k 后子问题min d^T ys.t. B y ≥ b - A x_ky ≥ 0设对偶变量为 u ≥ 0子问题对偶为max u^T (b - A x_k)s.t. B^T u ≤ du ≥ 0如果对偶问题有界最优解为 u_k则可生成最优割η ≥ u_k^T (b - A x)这条割的本质是对任意整数解 x运行成本的下界都可以用当前对偶乘子近似。随着迭代增加割越来越多η 被逐渐“托高”直到逼近真实运行成本。如果原问题不可行即对偶问题无界则找一个极方向 w使得 w^T (b - A x) ≤ 0作为可行割加到主问题切割掉不可行的 x。实际编程中我通常通过向子问题添加人工松弛变量来统一判断可行性和获取乘子。2.4 迭代收敛与停止准则GBD迭代需要维护两个界下界 LB 主问题目标函数最优值随着切割增加只增不减上界 UB 主问题解的整数变量 x_k 代入子问题得到的总成本 c^T x_k f_k注意总成本要加上投资成本而不是只用子问题的运行成本。上下界之差满足容差时停止比如(UB - LB) / UB ≤ ε实际操作中ε 常取 0.01 或 0.001具体看精度需求。收敛慢时有时会用“相对间隙”来做软停机并在达到最大迭代次数后输出当前最佳可行解。最佳可行解并不一定是最后一次主问题解而是历史上 UB 最小的那个 x。3. Matlab实现细节与关键代码3.1 系统参数与数据结构设计Matlab实现的第一步不是写求解器调用而是定义好数据结构和接口。我用一个struct保存系统参数例子如下% system.m system.T 24; % 调度时段 system.dt 1; % 时间步长小时 system.elec_load [...]; % 电负荷 1x24 system.heat_load [...]; % 热负荷 1x24 system.cool_load [...]; % 冷负荷如有 system.cchp.cap_min 0; system.cchp.cap_max 10; % MW system.cchp.inv_coef 2.5; % 万元/MW system.cchp.eta_e 0.35; % 发电效率 system.cchp.eta_h 0.45; % 供热效率 system.grid.price_buy [...]; % 分时购电价 1x24 system.gas.price 0.35; % 天然气价格接口变量我建议命名统一比如主问题传给子问题的决策用x_fixed包含is_build和capacity两个字段x_fixed.is_build [1 0 1]; % 三个候选设备投建状态 x_fixed.capacity [5 0 8]; % 对应容量这样做的好处是子问题函数只管读x_fixed不必关心主问题里变量的排列顺序。3.2 主问题Matlab实现YALMIP / intlinprog主问题我用YALMIP建模因为YALMIP对0-1变量和支持Benders割的累加非常友好。你需要提前装好YALMIP和一个MILP求解器比如Gurobi、CPLEX或者Matlab自带的intlinprog。核心代码片段function [x_opt, eta_opt, LB] solve_master(cuts, params) % 定义变量 x binvar(1, params.n_device, full); % 投建状态 C sdpvar(1, params.n_device, full); % 容量 eta sdpvar(1); % 运行成本近似 % 目标 inv_cost sum(params.inv_coef .* C); objective inv_cost eta; % 约束容量上下限逻辑约束 constraints []; for i 1:params.n_device constraints [constraints, params.cap_min(i)*x(i) C(i) params.cap_max(i)*x(i)]; end constraints [constraints, sum(x) params.max_device]; % 添加Benders割 for k 1:length(cuts) cut cuts(k); % 最优割: eta cut.offset cut.coef * [x; C] constraints [constraints, eta cut.offset cut.coef_x * x cut.coef_C * C]; end % 求解 ops sdpsettings(solver, gurobi, verbose, 0); optimize(constraints, objective, ops); x_opt round(value(x)); C_opt value(C); eta_opt value(eta); LB value(objective); end这里我先把x和C都作为主问题变量投建状态影响容量范围。实际项目中如果容量是离散档位也可以用整数变量表示档位。cut结构体里存了当前割的常数项和对x、C的系数这是对接子问题乘子的关键。注意不要在主问题中把运行成本直接写成变量的大规模组合否则又退化成“超级MILP”。主问题只能通过Benders割来隐含表达运行成本。3.3 子问题Matlab实现对偶乘子提取子问题给定 x_k 后求解运行优化。我用YALMIP写调度模型并利用dual函数提取约束的对偶乘子。这里有个经验YALMIP的dual(constraint)只能在求解器返回对偶信息后使用不是所有求解器都返回对偶乘子。Gurobi和CPLEX返回linprog也返回但部分免费求解器不行。核心代码function [obj, dual_eq, dual_ineq, feasible] solve_subproblem(x_fixed, params) % 解析设备配置 n_build length(x_fixed.is_build); % 定义运行变量 P_cchp sdpvar(1, params.T); H_cchp sdpvar(1, params.T); P_grid sdpvar(1, params.T); P_gb sdpvar(1, params.T); % 燃气锅炉供热 E_soc sdpvar(1, params.T); % 储能SOC P_ch sdpvar(1, params.T); P_dis sdpvar(1, params.T); % 目标运行成本 cost_buy params.grid.price_buy * P_grid; cost_gas params.gas.price * (P_cchp P_gb / params.gb_eta) / params.cchp.eta_e; objective cost_buy cost_gas; constraints []; % 能量平衡 constraints [constraints, P_grid P_cchp P_dis - P_ch params.elec_load]; constraints [constraints, H_cchp P_gb params.heat_load]; % CCHP模型 constraints [constraints, H_cchp P_cchp * params.cchp.eta_h / params.cchp.eta_e]; constraints [constraints, 0 P_cchp x_fixed.capacity(1)]; % 储能模型 constraints [constraints, E_soc(1) params.E_init]; for t 2:params.T constraints [constraints, E_soc(t) E_soc(t-1) P_ch(t)*params.eta_ch - P_dis(t)/params.eta_dis]; end constraints [constraints, 0 E_soc x_fixed.capacity(end)]; constraints [constraints, 0 P_ch x_fixed.capacity(end)*0.5]; constraints [constraints, 0 P_dis x_fixed.capacity(end)*0.5]; % 求解 ops sdpsettings(solver, gurobi, verbose, 0); sol optimize(constraints, objective, ops); feasible sol.problem 0; if feasible obj value(objective); % 提取等式约束和不等式约束对偶乘子 dual_eq dual(constraints(1)); % 电平衡乘子 % 实际项目中需要根据约束索引小心提取 else obj inf; dual_eq []; dual_ineq []; end end关于对偶提取要注意YALMIP中dual返回的是拉格朗日乘子符号取决于约束形式。等号约束的对偶没有简单符号规定所以在生成Benders割时要把边界的b - A x计算清楚。我习惯先手动验算一个小算例确认提取的乘子符号正确再写通用函数。3.4 迭代框架与切割添加GBD的迭代框架是整个项目的中枢神经我写成run_gbd.m。这个文件里需要维护几个数组cuts_offset、cuts_coef_x、cuts_coef_C分别存储每条割的常数项和系数。每次迭代先解主问题再把主问题的解传给子问题最后根据子问题的返回信息决定增加最优割还是可行割。% run_gbd.m clear; clc; params init_system(); % 初始化系统参数 max_iter 50; tol_gap 0.01; LB -inf; UB inf; cuts []; x_best []; best_cost inf; for iter 1:max_iter % 1. 求解主问题 [x_opt, C_opt, LB] solve_master(cuts, params); % 2. 把整数解传给子问题 x_fixed.is_build x_opt; x_fixed.capacity C_opt; % 3. 求解子问题 [run_cost, dual_info, feasible] solve_subproblem(x_fixed, params); if feasible total_cost sum(params.inv_coef .* C_opt) run_cost; if total_cost best_cost best_cost total_cost; x_best x_opt; C_best C_opt; end UB min(UB, total_cost); % 生成最优割 cut.offset dual_info.rho0; cut.coef_x dual_info.rho_x; cut.coef_C dual_info.rho_C; cuts [cuts, cut]; fprintf(Iter %d: LB%.4f, UB%.4f, gap%.2f%%\n, iter, LB, UB, abs(UB-LB)/UB*100); if abs(UB - LB) / UB tol_gap break; end else % 子问题不可行添加可行割需额外实现可行性子问题 % 实际中也可直接通过松弛约束获得可行割 fprintf(Iter %d: infeasible subproblem, add feasibility cut\n, iter); end end这个框架里dual_info需要自己封装。我通常不直接从子问题返回原始乘子而是返回“已经和目标函数、常数项合并好的割系数”。这样主问题函数只需要做非常简单的线性约束添加不容易出错。3.5 代码完整流程示例一个可运行的项目代码文件建议这样组织project/ init_system.m solve_master.m solve_subproblem.m solve_feasibility_subproblem.m run_gbd.m plot_results.m我这边有一个典型算例简化后的输出效果收敛曲线会是阶梯状下降的UB和阶梯状上升的LB两条线逐渐靠拢。如果LB长期不变、UB不断波动多半是割的系数符号错了或者对偶乘子提取错位。这里先埋个伏笔后面第五部分专门讲排查。4. 数值实验与结果分析4.1 典型算例设置我直接用一个简化园区算例来演示。假设系统包含3个候选设备CCHP、燃气锅炉和电储能。电负荷和热负荷给24小时典型日数据分时电价在峰、平、谷三个时段变化。目标是最小化一年的投资和运行成本用典型日运行成本乘以365代表全年运行成本这是工程上常用的简化虽然不精确但足以验证GBD算法的有效性。设备参数我设为设备容量范围(MW)投资系数(万元/MW)效率/损耗CCHP0~102.5电效率0.35、热效率0.45燃气锅炉0~81.2热效率0.85电储能0~51.8充放效率0.95、0.92负荷数据不在这里完整列出你可以用任意典型日数据。关键是CCHP存在热电比约1.29而热负荷峰和电负荷峰不一定同步这时候储能就能平抑偏差所以最终规划结果大概率不会只选单一设备。4.2 收敛过程演示我实际跑下来前几次迭代UB波动比较明显。原因很直观刚开始主问题没有足够的割会给出一个运行成本非常低的乐观估计LB很低。第一个整数解往往投资偏保守子问题算出的运行成本很高UB很大。随着割不断加入主问题逐渐“学会”不同设备配置下的真实运行成本趋势LB和UB逐步逼近。以一个随机生成的负荷数据为例收敛过程中的里程碑数据大致如下迭代次数LB(万元)UB(万元)相对间隙1320.5785.259.2%5452.3620.727.1%10510.8575.411.2%15534.2552.13.2%20541.6548.31.2%25544.0546.50.46%可以看到GBD的收敛速度并不均匀中后期切割效果明显变缓。这时不要傻等可以设置最大迭代次数为30再设相对间隙阈值1%。实际项目中如果1%的间隙可以接受一般20次左右就够。4.3 规划结果解读最终最优解里的设备配置反映的是“性价比”最高的组合。比如在电价高峰时段贵、天然气相对便宜的场景下CCHP会更倾向于扩大容量利用燃气发电同时产热降低购电成本但如果热负荷总量不大CCHP容量太大反而会导致低负荷率运行效率下降所以GBD会找到一个平衡点。我特别喜欢看GBD给出的“割”在迭代过程中的变化。每一条割本质上是一个超平面它告诉主问题“在某个设备组合附近运行成本大概不低于某个值”。当这些超平面越加越多主问题的可行域被不断“削”到最优解附近。这也是为什么Benders割也被叫做“切平面”的原因——像削土豆一样一刀一刀把不可行和非最优的区域削掉。这个几何直觉能帮你理解算法也能帮你给导师或同事解释结果。5. 常见问题与排查技巧实录5.1 对偶信息获取失败YALMIP在某些求解器下拿不到对偶乘子最常见的原因是求解器设成了内点法但不返回乘子或者模型里有不可微的部分。我的建议是优先使用Gurobi或CPLEX并且确认子问题是严格的LP没有整数变量。如果你的子问题里含有储能充放电的0-1变量比如“禁止同时充放”那么子问题就成了MILP常规对偶乘子就不存在GBD也就走不通了。如果子问题必须包含整数变量可以考虑把“禁止同时充放”这类约束去掉因为只要充放电效率都小于1且电价为正最优解天然不会同时充放。这是一个非常实用的建模技巧我每次做IES调度都会检查一遍是否可以把整数变量消掉。另一个检查点是YALMIP的dual返回的是constraint对象对应的乘子而不是解向量某个分量。写代码时最好把约束存成con_eq [P_grid P_cchp P_dis - P_ch load];然后用dual(con_eq(1))提取。如果约束列表是通过循环拼接的索引错位几乎无法避免建议手动编号。5.2 收敛慢、切割震荡GBD收敛慢的原因多半是割的质量差。优质割需要子问题对偶乘子充足而不是只能拿到目标值。如果某个约束没有参与优化松约束它的对偶乘子为0这不影响正确性但可能导致割只在一小部分区域有效主问题在多个不优的整数解之间反复横跳。处理办法有几个在主问题中添加简单的逻辑约束比如“如果CCHP不建则燃气锅炉必须建”减少无效整数组合。给割增加一个小的正则项比如在子问题目标中添加对x的微小二次惩罚可以让对偶乘子更稳定但要注意这改变了原问题。改用“多割”策略如果问题天然可以分解为多个场景比如多个典型日可以让每个场景各自生成一条割而不是把多场景合并成一个子问题生成一条割。多割通常能显著加速收敛。我在实际项目中用过最有效的手段是“热启动”把上一次主问题求解后得到的整数解作为下一次主问题的初始MIP start让分支定界更快找到好的可行解。Matlab的intlinprog支持通过x0参数传入初始点YALMIP可以通过assign后再optimize来间接实现不过效果因求解器而异。5.3 子问题无可行解如果在某次主问题给出的设备配置下子问题找不到可行运行方案说明这个配置在实际运行中无法满足负荷。这通常发生在设备容量过小或者储能初末SOC约束过紧的情况。处理办法是加可行性割但很多初学者容易把不可行子问题直接跳过这样会让LB失去真实性最终结果错误。我建议统一做法构建一个“可行性恢复子问题”把原约束分成硬约束和软约束软约束添加非负松弛变量目标是最小化松弛变量之和。当原子问题不可行时求解这个恢复问题得到松弛变量对应的对偶乘子再生成可行割。这个方法在工程中很成熟但代码量会增加我在项目里用一个函数solve_feasibility_subproblem实现。如果只是为了快速验证算法也可以先不处理不可行情况只在算例参数上保证所有设备组合都可行但正式报告里你需要说明这一点。5.4 求解器选择与Matlab版本问题整套框架对Matlab版本要求不高但建议R2020b以上因为intlinprog的性能和接口都有改进。如果你本机没有Gurobi或CPLEX可以先用Matlab自带intlinprog跑通主问题子问题用linprog代价是变量规模大时求解偏慢但算法正确性不受影响。YALMIP可以自动调用多个求解器你只要在sdpsettings里指定solver字段即可。还有一种常见坑YALMIP有缓存历史变量的类如果在循环里反复调用optimize旧的sdpvar对象会占用内存迭代次数多了以后速度下降。可以在每次迭代前用clear局部变量或者把它封装成函数让Matlab的局部作用域自动清理。封装成函数的另一个好处是不会因为主问题和子问题共用了同名变量而导致隐蔽bug。5.5 一个重要提醒GBD的收敛性与初始点最后提醒一个经常被忽略的问题GBD的收敛性依赖于子问题的凸性。如果你的综合能源系统模型里加入了非凸的设备效率曲线、启动成本、开关机0-1变量那么GBD可能无法保证收敛到全局最优。在项目报告里一定要明确说明你的子问题是凸的或只做局部最优近似。如果非要处理非凸子问题可以考虑用分段线性化近似效率曲线把非凸问题转化为MILP但这时对偶乘子的问题又会回来。一个折中方案是“拉格朗日松弛 启发式修复”不过这套方法已经超出GBD范畴了。我个人经验是在IES规划阶段用线性化模型已经足够支撑工程决策非凸运行细节可以在运行层再用更精细的模型校验。最后说点实操体会我自己在搭这套框架时踩得最深的坑就是“对偶乘子符号”。Benders割的公式看着简单但一旦等式约束多、变量名绕系数对不上就能让你白调两天。我的解决办法是写一个只有两个设备、两时段的微型算例手算一次子问题的对偶解再用代码跑一遍确认乘子符号和数值一致。微型算例调试通过后再放开到24时段、多个设备之后基本畅通无阻。另外一个很实用的技巧是把每次迭代的主问题解、子问题运行成本、割的系数全部打印到文件里用Matlab的save(iter_data.mat, iter, x_opt, run_cost, cuts)保留迭代历史。这样不仅方便自己复盘也能在论文或项目报告里画出漂亮的收敛曲线向别人证明算法确实在收敛。做优化算法研究可视化收敛过程是基本素养别光给个最终结果。这段经历也让我养成了一个习惯任何分解算法的代码实现都要先保证“每一步中间结果都可视”否则网格调参会疯掉。如果后续你想扩展这个项目可以考虑从单目标扩展到多目标投资成本碳排放或者把确定性模型升级为两阶段鲁棒优化鲁棒优化的CCG算法和GBD在思路上有很多共通之处迁移起来会很快。希望这套Matlab实现能帮你在综合能源系统规划里少走点弯路。