
做综合能源系统优化规划这个方向也有几年了每次新课题都会撞上同一类问题投资决策和运行调度天然耦合在一起变量规模一大单层MILP直接卡成“死机模拟器”。这篇博文从我最近在跑的Matlab项目说起——基于广义Benders分解法的综合能源系统优化规划把模型的拆解思路、分解方法原理、Matlab里主问题子问题怎么写、常见坑怎么避一次性梳理清楚。适合正在入门综合能源系统规划、或者手头有类似混合整数规划问题想用分解法求解的读者也适合那些已经跑通单层模型、但一增加场景数和设备数就内存爆表的同学。1. 项目选题拆解综合能源系统规划问题为什么难1.1 综合能源系统规划到底在优化什么综合能源系统的“综合”二字体现在多能互补上。一个典型园区级系统通常以电、气作为外部输入通过燃气轮机CHP、燃气锅炉、电锅炉、热泵、电制冷机、吸收式制冷机等设备同时满足电、热、冷三类负荷必要时加上光伏、风电和储能。规划要做的事很直接在候选设备清单里选出最优的设备组合和容量。比如CHP装多大、锅炉装几台、储电/储热容量多少。这个“选”字背后是纯整数变量而一旦牵扯到全年8760小时或典型日多场景的运行模拟每个时段的设备出力、储能充放又是连续变量。于是优化问题天然变成一个混合整数规划而且时段规模一大规模会非常夸张。规划目标也不仅仅是省钱那么简单。过去大家只做经济性即年化投资加运行成本最低现在越来越多研究加入碳排放约束或者干脆把碳排放纳入目标函数。这就让问题的复杂度和非线性程度再上一个台阶。1.2 为什么单层优化模型会“爆炸”我最早接触这个课题时第一反应是直接建一个单层MILP模型丢给求解器。小规模算例没问题10个时段、5台设备一秒钟就有结果。但一旦场景数从10个变成100个或者加入储能这种时段耦合变量求解时间会从秒级飙到小时级有时候连可行解都找不到。根本原因在于整数变量和连续变量纠缠在一起。求解器在分支定界时每探索一个整数节点都要解一个包含全部时段变量的连续优化问题如果每个节点上的LP规模本身就很大整个搜索树的成本就是灾难性的。广义Benders分解法的思路就是把这团乱麻剪开。它把原问题拆成两层——主问题只管投资子问题只管运行。子问题在给定投资方案下求解变成一个纯线性规划LP规模再大也是LP求解器跑起来很快主问题只包含投资变量和少量辅助变量一个小MILP。两层之间通过“切平面”交换信息迭代逼近原问题的最优解。2. 广义Benders分解法原理与思路精讲2.1 分解法的核心思想投资决策者与运行调度员的“谈判”Benders分解的思想非常朴素我经常用买房装修来类比。投资决策者相当于房主先拍板房子结构、面积、装修预算运行调度员相当于施工队在给定预算下算出实际装修成本和哪些地方会超出预算。如果超了施工队反馈给房主“这条预算线不可行你得加钱或者改方案”房主据此调整方案再来一轮。如此反复直到双方对预算达成一致。映射到综合能源系统里主问题先给出一组设备容量配置子问题在固定容量下做多时段的运行优化求出最小运行成本和购能费用如果这个配置根本满足不了负荷平衡子问题就发一个“可行性切平面”告诉主问题“容量不够别选这个方案”。主问题收集到反馈后继续优化投资方案。整个过程循环往复直到目标函数的上界和下界收敛到同一个值。这个“上下界收敛”是Benders分解正确性的基石。每次迭代主问题求出的目标值是下界因为运行成本是用切平面近似的不是精确值子问题求出的运行成本加上投资成本是上界因为对应的投资方案实际可行。上界和下界的间距越来越小最终gap小到设定阈值时我们就认为找到了最优方案。2.2 主问题、子问题与切平面的数学表达为了让你不迷路我把数学形式捋一遍。考虑一个抽象的两阶段优化问题min c^T x d^T y s.t. A y ≥ b - T x x ∈ X离散可行域 y ∈ Y连续可行域其中x是投资变量y是运行变量A y ≥ b - T x是所有耦合约束比如能量平衡和设备出力约束。给定一组投资方案x̄子问题就是min d^T y s.t. A y ≥ b - T x̄ y ∈ Y这是一个线性规划。求解它如果可行我们会得到最优运行成本和对应的对偶乘子λ*。对偶乘子其实反映了“当投资方案x变动一个单位时运行成本变化多少”这正是主问题需要的反馈信息。根据λ*可以生成一条最优性切平面θ ≥ λ*^T (b - T x) 常数修正项这里的θ是主问题里的一个连续辅助变量用来表示运行成本的近似值。每生成一条切平面主问题对运行成本的近似就精确一分。如果子问题不可行说明当前投资方案x̄根本没法满足所有负荷需求。此时不能直接跳过我们要在子问题里加非负松弛变量s把约束放宽为A y s ≥ b - T x̄并求松弛量总和最小。然后拿到对偶乘子μ*生成可行性切平面0 ≥ μ*^T (b - T x)这条切平面的直观含义是主问题如果继续往这个不可行区域里选投资方案就必须付出代价切平面会把这个区域从主问题的可行域里“切掉”。2.3 广义Benders与经典Benders的区别和适用边界经典Benders分解最初是针对混合整数线性规划提出的子问题是线性规划通过线性对偶理论获取乘子。广义Benders分解Geoffrion, 1972把适用范围扩展到凸非线性优化只要子问题是凸的就可以用KKT条件或对偶信息生成切平面主问题仍然保持线性或混合整数形式。对于综合能源系统规划而言这个扩展意义重大。因为实际模型里常出现设备效率随负载率变化、燃气轮机热电联产特性曲线、储能充放损耗效率等非线性关系。只要这些关系不导致非凸广义Benders分解就能在理论上保证收敛到全局最优。不过必须提醒一句如果模型里加入了潮流方程这类非凸约束比如配电网交流潮流或天然气网管流方程广义Benders分解的收敛性就不再被数学理论保护可能收敛到局部最优。实践中有两种应对方式一是把网络约束做凸松弛如二阶锥松弛让模型回到凸优化框架二是在工业界干脆只用分解法求“好”的可行方案不追求全局最优证明。3. 综合能源系统规划数学模型构建3.1 典型系统拓扑与供能设备我从实际项目里抽一个典型的园区级系统出来。外部输入是电网买电和天然气购入内部设备包括燃气轮机CHP电热联产、燃气锅炉补热、电锅炉、热泵电转热、电制冷机电转冷、吸收式制冷机热转冷再加一组蓄电池和蓄热罐屋顶铺光伏。负荷侧分三类电负荷、热负荷、冷负荷。冬夏负荷曲线差异很大所以通常不会直接用全年8760小时建模而是做典型场景聚类。比如按季节聚成4类典型日每类24小时再乘以对应天数权重。这样模型精度够用变量规模又可控。3.2 目标函数与决策变量目标函数我采用年化总成本最小这也是目前IES规划最主流的目标min 投资成本等年值 年运行成本购电 购气 运维投资成本要把一次性建设费用换算成等年值用资本回收系数CRF计算CRF (r * (1r)^n) / ((1r)^n - 1)其中r是社会折现率n是设备寿命。为什么要这样做因为投资是当期的几十亿运行是每年的费用两者不折算就相加等于苹果加橘子。决策变量分两层。投资层变量是设备是否建设0-1变量和建设容量连续变量有些代码里也把容量离散成若干档位做0-1选择。运行层变量是每个典型日各时刻的设备出力、储能充放功率、储能SOC状态、从电网购电量和从气网购气量。运行变量数量多但全是连续变量这正好是Benders分解喜欢解的LP。3.3 约束条件与建模细节约束是IES规划模型的灵魂马虎一点就会得到“设备装了但用不上”或“优化结果根本不可能运行”的荒唐结论。第一类是能量平衡约束。每个典型日每个时刻电、热、冷都必须满足供需平衡。比如电平衡购电量 CHP发电 光伏发电 蓄电放电 电负荷 电锅炉耗电 热泵耗电 电制冷机耗电 蓄电充电热平衡和冷平衡类似关键是注意CHP的热电联产关系它对整体的效率影响极大。第二类是设备运行约束。每台设备的出力有上下限不能超容量有爬坡率限制不能前后时刻跳变太大有条件的话还要考虑设备最小启停时间。这部分在单层模型里往往要引入启停机变量在Benders框架下一般留在子问题中处理因为一旦设备已经选型启停优化本身就是运行层的典型混合整数问题。不过我的项目里为了保持子问题是纯LP通常把设备启停容许为一个0-1变量再把0-1变量放到主问题一起决策或者在子问题用大M松弛成线性约束。第三类是储能约束。储能是所有约束里最容易出bug的。SOC要在时间上递推SOC(t1) SOC(t) * (1 - 自损耗) 充电功率 * 充电效率 - 放电功率 / 放电效率同时SOC有上下限充放电功率有上限通常还要加“同一时刻不能同时充放”的约束。处理“不能同时充放”最简单的方法是引入0-1变量z充电时z1放电时z0用大M法建模。但如果子问题没有0-1变量就得换成平衡约束之外的技巧比如把充放功率差作为自由变量并依靠目标函数中的成本系数自然避免同时充放。第四类是外部购能约束。电网购电量上限、气网购气量上限、光伏出力上限由场景光照强度决定。这些约束很容易被忽略但做规划时必须考虑否则优化结果可能要求在一个实际根本不可能达到的购电功率下运行。4. Matlab代码架构与核心实现4.1 整体程序框架与文件组织我最怕拿到一坨脚本串起来的项目变量名全是a1、b2跑完一趟自己都不敢改。这个项目从一开始就按模块组织目录结构大概是这样IES_GBD/ main_GBD_IES.m % 主程序控制迭代循环 data_input.m % 所有基础数据与场景定义 build_equipment.m % 候选设备参数与投资成本 master_problem.m % 主问题建模与求解 subproblem.m % 子问题建模与求解 generate_cuts.m % 根据子问题结果生成切平面 plot_results.m % 收敛曲线和规划结果可视化 utils/ build_balance_matrix.m % 构造能量平衡矩阵 build_storage_matrix.m % 构造储能SOC矩阵主程序的迭代循环是整个代码的核心骨架思路也是固定的初始化x0初始可行投资方案LB -infUB inf while gap tol 且 迭代次数 max_iter % 1. 给定当前x_k求解子问题 [cost_op, feasible, lambda] subproblem(x_k) % 2. 更新上界 if feasible UB min(UB, investment_cost(x_k) cost_op) end % 3. 生成切平面最优性或可行性 cuts generate_cuts(x_k, feasible, lambda) % 4. 把切平面加入主问题 master_problem.add_cuts(cuts) % 5. 求解主问题得到新的x和辅助变量theta [x_next, LB] master_problem.solve() % 6. 计算gap gap (UB - LB) / abs(UB) 1e-9 end有一个新手容易踩的坑主问题一开始没有切平面时θ没有下界约束主问题会无界。解决方法是给θ加一个大负数下界比如θ ≥ -1e6或者在主问题里直接包含一个初始可行方案的子问题结果作为第一条切平面。4.2 子问题实现从linprog到向量化子问题在固定x̄之后是典型LP直接用Matlab的linprog求解。关键在于动态生成约束矩阵因为不同x̄会改变约束的右端项和部分系数。我把运行变量按“时刻堆叠”的方式组织先排所有设备的电出力再排热出力、冷出力、储能最后排购电量和购气量。这样约束矩阵可以用分块结构拼出来而不是用循环一行一行加否则T24时还好T1000时会慢到怀疑人生。linprog的对偶乘子通过输出参数lambda获得options optimoptions(linprog, Algorithm, dual-simplex, ... Display, off, ConstraintTolerance, 1e-8); [x_opt, fval, exitflag, output, lambda] linprog(f, Aineq, bineq, ... Aeq, beq, lb, ub, options);注意lambda是一个结构体包含ineqlin、eqlin、lower、upper等字段。生成切平面时要取的是ineqlin和eqlin里的乘子对应生成最优性切平面如果是不可行情况求解带松弛变量的版本后从松弛约束的对偶变量拿到可行性切平面系数。子问题里我还会记录每个时刻的能量平衡约束影子价格这不仅是切平面产物还能辅助分析“系统在这个时段最缺的是什么能”。4.3 主问题MILP建模与切平面循环主问题只包含投资变量x、辅助变量θ以及切平面累积下来的线性约束。这是一个小规模MILP用intlinprog即可求解。关键点在于切平面的动态加入。每次迭代后把新切平面追加到Aineq矩阵末尾。切平面多了之后主问题规模会不断膨胀其中很多切平面可能早就被其他更强切平面支配了。实践中我每20轮做一次清理计算每条切平面在当前主问题最优解处的取值如果松弛量大于一个小阈值说明这条切平面已经失去约束力直接删掉。这样做能显著控制主问题求解时间。intlinprog求解主问题的核心代码大致长这样% x是离散投资变量theta是连续变量 % 目标函数投资成本的线性项 theta f_master [investment_unit_cost; 1.0]; intcon 1:num_x; % 只对投资变量要求整数 lb_master [zeros(num_x,1); -1e6]; ub_master [ones(num_x,1); 1e6]; [x_milp, obj_master] intlinprog(f_master, intcon, ... A_cuts, b_cuts, Aeq_master, beq_master, lb_master, ub_master, options);4.4 收敛判据与参数设置收敛判据是最容易和求解器数值容差“打架”的地方。我的经验是Benders分解的收敛gap不要低于求解器本身的LP/MILP容差太多否则上界和下界会因为数值噪声来回抖动gap永远收敛不到理想值。通常设相对gap 1e-3或1e-4就足够工程使用了。此外还要设定最大迭代次数作为兜底防止模型有问题时无限循环。有些子问题非常便宜几秒钟一把就算迭代100次也能接受但如果子问题是带网络约束的复杂LP每次要跑几分钟就必须对迭代次数做控制。5. 算例验证与结果分析5.1 测试场景设置我用一个简化的园区算例验证代码。候选设备包括1台CHP可选0/1容量5MW、2台燃气锅炉各3MW、1台电锅炉2MW、1台热泵1MW、1组光伏最大2MWp、一组蓄电池最大4MWh、一组蓄热罐最大8MWh。负荷数据按冬季、夏季、过渡季三个典型日循环每天24小时。为了公平对比我还同时跑了一个相同规模的单层MILP作为对照基准。5.2 收敛行为与迭代效果实测下来从无切平面初始状态开始前5轮gap下降非常快直接从几十个百分点压到5%以内之后进入“细磨”阶段每轮可能只下降零点几个百分点。我的算例大约在22轮收敛到1%以内的gap全程耗时约40秒。作为对照单层MILP在同样规模下求解耗时约150秒而且随着场景数翻倍单层模型的求解时间增长接近指数而Benders框架下子问题只是LP增长相对线性。收敛曲线画出来能看到一个典型现象下界稳步上升并长期低于最优值上界在早期波动因为某些投资方案收益好但运行成本估算不准确后期两条曲线慢慢贴合。如果上界长时间不动大概率是主问题找不到更好的投资方案这时可以怀疑切平面不够强或者主问题的MILP最优解在子问题中被判为不可行导致被反复“切掉”。5.3 规划结果与传统方法的对比最终规划结果和单层MILP几乎一致CHP被选中容量5MW燃气锅炉选了一台因为CHP的余热已经覆盖了大部分热负荷光伏和蓄电池被部分选上但蓄热罐容量被削到很低的水平因为燃气锅炉跟CHP的热出力已经足够。目标函数的相对偏差小于0.3%这个误差主要来自数值容差而非算法偏差。更有意思的是运行结果在冬季典型日里CHP几乎全天满发蓄电池在晚高峰放电、凌晨低谷充电在过渡季由于热负荷较小CHP发电成为“被迫”副产品多余电量反卖给电网此时热泵的启停策略显得特别关键。这些都验证了“规划结果必须在运行层面可执行”这个初衷。5.4 不同场景规模下的性能表现为了测试扩展性我把场景数从3个典型日扩到30个随机场景。单层MILP的变量数直接冲到几十万求解时间超过1小时还没收敛Benders框架下每次迭代需要依次求解30个子问题但因为子问题之间互相独立我用parfor并行跑总耗时只从40秒增加到约120秒gap收敛到1%用了28轮。这个结果说明广义Benders分解法在大规模场景下具备明显的实用价值这也是它在IES规划里被反复使用的原因之一。6. 常见问题与调试实录6.1 子问题不可行怎么处理这是Benders分解实现里最频繁遇到、也最容易被忽视的问题。子问题不可行分为两类第一类是建模bug导致永远不可行比如能量平衡矩阵某一行写错了或者储能SOC初值设成了负值。这种需要回过头去逐行检查约束矩阵而不是急着加可行化松弛。我的经验是对单时刻需求做一次“可行性检验”固定一个bf的平凡方案——所有设备都买最大容量——此时如果子问题仍然是不可行的那基本可以确定是模型bug。第二类是当前投资方案确实容量不足导致不可行比如光伏接入太多但没配储能夜间无光时平衡被破坏。这种情况下用带松弛变量的子问题求解然后生成可行性切平面。松弛变量的惩罚系数要设得够大但不能大到淹没正常成本量纲。我通常设惩罚系数为正常目标函数最大数量级的100倍以上并在日志里监控松弛量是否真的趋向于0。如果多次迭代后松弛量仍很大说明问题可能确实无解需要调整候选设备集合。6.2 收敛慢的三大原因与对策第一种是对偶乘子不唯一。LP存在退化时同一个子问题可能返回多组不同的对偶乘子生成的切平面忽强忽弱。解决方案是给子问题加上非常小的二次正则项比如0.0001 * ||y||^2让LP变成一个严格凸二次规划对偶解唯一切平面质量大幅提升。代价是求解时间略增但通常值得。第二种是切平面太弱。一次性只加1条切平面信息量太少。实践中我会同时加入“上一轮所有可行子问题的切平面”以及几条“从松弛问题得到的可行性切平面”相当于一次反馈多个方向的信息。多切平面技巧Multi-cut对收敛速度的提升十分明显尤其当目标函数包含多个典型日场景时每个场景生成一条子问题切平面主问题一次吞下所有反馈。第三种是数值尺度差异太大。投资成本动辄千万运行成本每小时只有几百切平面系数跨了四五个数量级。这个问题会让MILP求解器的数值容差失效表现为gap长期轻微震荡不收敛。解法是把所有成本统一折算成万元或者干脆把目标函数整体归一化再不行就给每个变量做单位换算。6.3 Matlab环境下的数值与性能陷阱Matlab写优化代码坑比想象中多。先说编码问题如果你用较新版本的Matlab打开老项目可能会碰到中文注释乱码本质是源文件编码是UTF-8或者GB18030导致的。建议在保存代码时统一用UTF-8并且编辑器的“文件编码”设置里显式指定别让Matlab自动猜。再说优化工具箱linprog默认的Algorithm选项在旧版本是interior-point-leacy新版本是dual-simplex。我强烈建议显式指定dual-simplex因为Benders子问题会反复求解大量结构接近的LPdual-simplex可以利用上一轮的基矩阵作为热启动速度提升非常明显。intlinprog方面如果主问题规模不大默认的cut策略就够用不要盲目打开所有启发式选项有时反而增加求解时间。第三个坑是矩阵构建方式。用for循环拼接Aeq矩阵在T24时可接受T8760时就是灾难。建议先计算稀疏矩阵的非零元坐标再用sparse一次性构造。我见过太多人因为这里没向量化一跑就是几十分钟。还有一点尽量用正规渠道的Matlab许可证。我以前在项目组见过有人用来路不明的版本结果某个算例里linprog返回的目标值跟独立验证差了好几个数量级查了很久最终发现问题出在求解器库不完整。学校和企业通常都有正版License稳定性和数值可靠性都更有保障。6.4 调试技巧实录迭代循环跑起来之后日志输出要能救命。我每次迭代都会打印当前迭代数、UB、LB、gap、子问题可行性、切平面数量、主问题求解时间。日志格式固定方便用脚本批量分析。还有一个技巧把每个子问题的最优运行成本单独存成向量迭代结束后对比“哪些场景在驱动目标函数的增长”。很多时候你会发现某个特定典型日比如夏季晚高峰占了总运行成本的大头这能反向指导你调整候选设备方案比如增加储能容量或者补一台快速响应设备。如果主问题连续好几轮给出的x都一样但gap就是不收敛那八成是切平面加得不全主问题和子问题在反复就同一个方案互相“扯皮”。此时可以用一个临时实验把主问题的MILP解优选项关掉只要求返回一个可行解同时多生成几条切平面通常能打破僵局。7. 扩展方向与一点个人体会做广义Benders分解这几年一个很深的感受是这个方法在综合能源系统规划里的价值不止于“能跑通”更在于它天然适配两阶段决策结构。多场景随机规划直接把场景塞给子问题两阶段鲁棒优化把不确定集体现在子问题的对偶判断中数据驱动方法可以用机器学习模型给子问题做一个代理加速切平面的生成。我后来在这套代码的基础上加了碳排放约束和碳交易价格改动也很小——只需要在目标函数里加一项再把碳排放约束写进子问题即可。最后分享一个我踩过多次坑后沉淀下来的小技巧在子问题目标函数里加一个非常小的线性正则项比如对所有储能设备出力加0.001倍的惩罚可以显著减少LP退化让切平面更干净。这个方法不改变原问题的最优解结构但对收敛稳定性帮助极大。我几乎在每个用Benders分解的项目里都这么干效果稳定。如果你打算在自己的项目里推广这套代码我建议一开始不要追求把模型建到最大、最全。先跑通一个只有电负荷和CHP的最小案例确认Benders循环收敛逻辑没问题再逐步加入热负荷、储能和光伏。每一步增加复杂度后都拿单层MILP的结果做一次交叉验证——这一步能帮你筛掉绝大多数自己没发现的约束bug。能量系统优化规划这件事模型越大越要谨慎分解法给了我们一把好“刀”但怎么用好它还是得一步步来。