ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

基于广义Benders分解法的综合能源系统规划优化与Matlab实现

基于广义Benders分解法的综合能源系统规划优化与Matlab实现 做综合能源系统优化规划的朋友应该都有共鸣——模型写起来容易算起来要命。一个稍微完整的园区级综合能源系统设备七八个起步加上0-1投建变量、连续运行变量、多能流耦合约束问题规模动辄几千个约束、上万个变量。直接扔给Gurobi或者Cplex算小案例还行稍微把时间尺度拉长到8760小时内存和求解时间双双爆炸。这篇博客我就拿实际做过的冷热电联供系统规划项目来聊讲清楚为什么我最终选了广义Benders分解法以及Matlab里这套算法具体是怎么落地实现的。1. 项目背景综合能源系统规划到底难在哪1.1 一个典型的园区级综合能源系统先说清楚对象。我这里的综合能源系统IES以冷热电联供CCHP为技术主线燃气轮机烧天然气发电发电后的余热通过余热锅炉回收一部分直接供热一部分驱动吸收式制冷机供冷缺口由燃气锅炉补充供热、电制冷机补充供冷电网作为电力备用电来源视情况加装电储能和热储能。这样的系统要同时满足电、热、冷三种负荷燃气、电力、热水/蒸汽、冷水多种能源流在设备之间互相耦合。从规划角度看这个系统要回答两个层面的问题第一哪些设备要建、建多大容量——这是投资决策第二在给定设备配置下每个时段各设备到底出多少力、从电网买多少电、从气网购多少气——这是运行决策。两个层面不是独立的设备容量建小了运行成本必然高负荷高峰可能扛不住容量建大了投资浪费。所以必须放到一个优化模型里联合求解这正是综合能源系统规划问题的核心难点所在。1.2 优化规划的数学模型长什么样把上面描述写成数学模型是一个典型的两阶段混合整数规划第一阶段变量设备投建0-1变量 y_i、设备容量连续变量 Cap_i第二阶段变量每个时段 t 的设备出力、储能充放电功率、购电购气量等连续变量 x_t目标函数年化总费用最小等于投资等年值加全年运行费用目标函数可以简洁地写成min C_inv(y, Cap) Σ_t C_ope(x_t) s.t. y_i ∈ {0,1}, Cap_min·y_i ≤ Cap_i ≤ Cap_max·y_i 电/热/冷多能平衡约束 设备出力上下限、爬坡约束、储能SOC递推约束如果按1小时粒度模拟全年就是8760个时段每个时段有几十个变量和约束总体是一个上万变量、上万约束的混合整数线性规划MILP。这还没考虑非线性——燃气轮机效率随机组负荷变化、储能老化成本、管网压力流量关系等一旦引入这些因素就变成混合整数非线性规划MINLP直接求解更加困难。1.3 为什么不能一口气算完很多朋友刚开始会问既然Gurobi、Cplex对MILP求解能力很强为什么还要搞分解算法我的实际体验是小规模算例确实可以直接刚正面例如设备种类三四个、典型日取几个十几个小时能出结果。但项目一旦要求精细比如做全年8760小时运行模拟、设备候选项扩展到七八个、还要考虑多场景对比和敏感性分析直接求解就会暴露两个问题内存爆炸。约束矩阵里整数变量和连续变量交织分支定界的搜索树巨大内存轻松吃掉几十个GB。求解时间不可控。一次全模型求解就要数小时甚至数天而实际项目中“改一个参数重新跑一遍”是家常便饭这种成本完全不可接受。更本质的原因是投资决策与运行调度在结构上天然分离给定设备配置之后运行子问题是一个规模较大但结构良好的线性规划LP非常容易求解。既然如此我们完全可以设计一种算法把投资决策和运行优化拆开反复迭代逼近原问题的最优解——这就是Benders分解类算法的核心动机。广义Benders分解法正是这一思想在非线性场景下的推广也是我最终采用的技术路线。2. 广义Benders分解法的核心原理2.1 从经典Benders到广义Benders经典Benders分解由Benders在1962年提出最初用于求解混合整数线性规划。核心思路是把问题拆成主问题Master Problem和子问题Subproblem主问题处理“难变量”通常是整数变量子问题在给定难变量取值后求解“易变量”连续变量的优化问题。子问题的最优解和对偶乘子以“割平面”的形式反馈给主问题主问题不断修正整数变量的取值直到上下界收敛。广义Benders分解Generalized Benders Decomposition简称GBD是Geoffrion在1972年做的推广关键突破在于经典Benders依赖线性规划的对偶理论而广义Benders把割平面的构造推广到了凸非线性规划只要子问题是凸优化问题就可以利用拉格朗日乘子或KKT条件来生成割平面。两者的关系可以用一张表简单对照特征经典Benders广义Benders适用问题混合整数线性规划凸混合整数非线性规划子问题类型线性规划LP凸非线性规划对偶信息来源LP对偶变量拉格朗日乘子 / KKT条件割平面构造基于LP对偶基于凸对偶理论实际项目中很多综合能源系统的模型经过线性化处理后用经典Benders就能跑但一旦设备效率曲线、储能成本等非线性项无法回避就必须按广义Benders的思路来处理这也是标题里“广义”两个字的实际意义所在。2.2 主问题与子问题的分工逻辑把广义Benders套到综合能源系统规划上分工非常清晰主问题负责投资决策。它求解的是一个规模很小的MILP变量只有设备投建状态 y 和容量 Cap外加一个辅助变量 α 用来近似运行成本。目标函数是“投资成本 α”其中 α 会被不断添加的Benders割约束逐步收紧。主问题求解速度快因为它不直接面对8760个时段的运行变量。子问题负责运行调度。给定主问题传过来的容量 Cap^k子问题求解一个大的线性规划或凸规划各设备在每个时段的出力、储能策略、购电购气量目标函数是运行成本最小化。子问题虽然规模大但是纯连续变量LP求解器处理起来非常高效。子问题返回的核心信息有两个一是当前容量配置下的最优运行成本二是约束对应的对偶乘子。这个对偶乘子就是经济学里的“影子价格”——它回答了主问题最关心的问题在某个时段、某个节点“多买1MW电”或者“增加1MW燃机容量”到底能带来多少运行成本的边际改善。割平面正是把这种边际信息带回主问题让主问题下一轮的容量决策更有方向性。2.3 两类割平面最优割与可行割Benders迭代过程中子问题可能给出两种结果对应两种割平面最优割子问题可行求解得到最优运行成本 Q^k 和对偶乘子 λ^k。此时主问题应该添加如下约束α ≥ Q^k (λ^k)^T · (Cap - Cap^k)它的含义很直观对于任意一个新的容量方案 Cap运行成本 α 至少不低于“当前方案的实际运行成本加上容量变化带来的边际修正”。这样就割掉了那些运行成本被过分低估的主问题解。可行割子问题不可行例如主问题给的容量太小某个时段的电平衡或热平衡根本无法满足。这时需要先求解一个“最小化不可行量”的问题引入松弛变量得到不可行程度 W^k 和对应的乘子 μ^k生成形式如下的约束0 ≥ W^k (μ^k)^T · (Cap - Cap^k)它强迫主问题在下一轮要么扩大容量要么改变投建组合消除不可行性。千万不要忽略可行割的作用——在实际迭代中初始解大概率不可行前几轮主要靠可行割把解拉回可行域。2.4 收敛机制与停机判据迭代过程中每次子问题可行都可以组合出原问题的一个完整可行解投资方案最优运行方案更新上界UB每次主问题求解出的目标函数值因为 α 是运行成本的下界估计所以主问题目标值是原问题的一个下界LB。最优解就被夹在上下界之间。停机判据一般用相对间隙gap (UB - LB) / UB工程上取 gap ≤ 0.011%或者 gap ≤ 0.022%就足够了没必要追求千分之一的精度——实际项目的电价、负荷预测本身就有几个百分点的误差强行收敛只会浪费时间。理论上GBD在问题满足凸性条件下保证收敛到全局最优但如果模型里含有非凸约束如非凸的机组可行域、非凸气体潮流方程GBD只能给出局部最优解这一点要心里有数。3. Matlab代码实现的关键环节3.1 模型搭建一个可复现的CCHP算例为了让代码讨论落地我给一个具体的算例参数后面所有代码都围绕它展开。假设园区小型综合能源系统候选设备四种燃气轮机、燃气锅炉、吸收式制冷机、电制冷机负荷数据取三个典型日冬、夏、过渡季代表全年。设备容量候选范围效率/COP投资单价元/kW年化系数燃气轮机 GT0~5 MW发电效率 0.35热电比 1.28000CRF燃气锅炉 GB0~10 MW热效率 0.91200CRF吸收式制冷 AC0~3 MWCOP 0.72500CRF电制冷 EC0~5 MWCOP 3.51500CRF投资等年值 投资单价 × 容量 × 资本回收系数 CRF。CRF由利率和寿命期算比如利率8%、寿命15年r 0.08; n_life 15; CRF r * (1 r)^n_life / ((1 r)^n_life - 1);电价采用峰谷分时电价气价按单位热值折算。这里提醒一句所有成本单位统一用“万元”功率用“MW”时间用“小时”避免数值数量级差太大导致求解器数值异常。3.2 子问题求解与对偶信息提取子问题的Matlab实现我用YALMIP建模求解器用linprog。关键代码框架如下function [oper_cost, lambda_power, feasible] solve_subproblem(cap_GT, cap_EC, load, price) T length(load.e); % 运行变量 P_GT sdpvar(1, T); % 燃气轮机发电出力 P_EC sdpvar(1, T); % 电制冷机耗电功率 P_buy sdpvar(1, T); % 电网购电 H_GB sdpvar(1, T); % 燃气锅炉产热 C_AC sdpvar(1, T); % 吸收式制冷出力 % 目标函数运行成本最小 cost_gas price.gas * (sum(P_GT) / 0.35 sum(H_GB) / 0.9) / 1.2; cost_elec sum(price.elec .* P_buy); obj cost_gas cost_elec; % 逐类定义约束顺序很重要后面dual要用 con_power [P_GT P_buy load.e P_EC]; con_heat [1.2 * P_GT H_GB load.h]; con_cool [C_AC 3.5 * P_EC load.c]; con_cap [0 P_GT cap_GT, 0 P_EC cap_EC, ... 0 P_buy price.pmax, 0 H_GB 10, 0 C_AC 3]; con [con_power, con_heat, con_cool, con_cap]; % 求解 ops sdpsettings(solver, linprog, verbose, 0); [~, error_flag] solve(con, obj, ops); if error_flag 0 oper_cost value(obj); % 提取对偶乘子顺序与con_power一致 lambda_power dual(con_power); feasible true; else oper_cost inf; lambda_power []; feasible false; end end这里有几个细节必须强调。第一约束要分组定义不要写成一个超长的约束数组。YALMIP的dual()函数返回的对偶乘子顺序与输入的约束顺序严格对应如果把电平衡、热平衡、容量约束混在一起后面提取乘子时特别容易错位排错能排到你怀疑人生。第二目标函数里的单位换算要看清楚燃气轮机消耗天然气的成本由发电量和效率折算燃气锅炉同理这里的0.35和0.9就是效率1.2是天然气的单位折热系数实际项目里要按你所在地区的气价热值认真标定。3.3 主问题求解与割平面添加主问题是一个小规模MILP我用YALMIP加Gurobi求解。主问题需要记录历史上所有Benders割每轮迭代不断追加新割function [y_new, cap_new, LB] solve_master_problem(cut_list, param) % 变量定义 n_dev 4; y binvar(n_dev, 1); % 投建0-1变量 cap sdpvar(n_dev, 1); % 容量连续变量 alpha sdpvar(1); % 运行成本下界估计 % 目标投资等年值 运行成本下界 inv_cost param.unit_cost * cap * param.CRF; obj inv_cost alpha; % 基本约束不投建设备容量必须为0 con []; con [con, param.cap_min .* y cap param.cap_max .* y]; % 循环添加所有Benders割 for m 1:length(cut_list) if strcmp(cut_list(m).type, optimality) con [con, alpha cut_list(m).Q ... cut_list(m).lambda * (cap - cut_list(m).cap_k)]; else con [con, cut_list(m).W ... cut_list(m).mu * (cap - cut_list(m).cap_k) 0]; end end ops sdpsettings(solver, gurobi, verbose, 0, mipgap, 0.005); optimize(con, obj, ops); y_new value(y); cap_new value(cap); LB value(obj); end为什么主问题用alpha作为辅助变量因为主问题不直接知道运行成本它只知道历史割平面给出的“运行成本的下界”。alpha在割的约束下被不断抬高最终逼近真实的运行成本。当这个逼近完成时主问题等价于原问题——这就是Benders分解的数学本质。实际操作中主问题的MIP gap可以设成0.5%不需要完全精确因为每次迭代都会更新割主问题不需要在一个不完整的模型上花费过高代价。3.4 迭代主循环把主问题和子问题串起来function GBD_main() % 初始化 UB 1e10; LB -1e10; cut_list {}; k 0; max_iter 50; tol 0.01; load_data(); param set_params(); while (UB - LB) / abs(UB) tol k max_iter k k 1; % 1. 求解主问题 [y_k, cap_k, LB] solve_master_problem(cut_list, param); % 2. 固定容量方案求解子问题 [cost_sub, lambda_power, feasible] ... solve_subproblem(cap_k(1), cap_k(4), load, price); if feasible % 3. 更新上界 ub_try param.unit_cost * cap_k * param.CRF cost_sub; if ub_try UB, UB ub_try; best struct(y,y_k,cap,cap_k); end % 4. 生成最优割 cut.type optimality; cut.Q cost_sub; cut.lambda lambda_power; cut.cap_k cap_k; else % 3. 生成可行割需要先求最小不可行量这里略去细节 cut.type feasibility; cut.W 100; cut.mu zeros(4,1); cut.cap_k cap_k; end cut_list{end1} cut; fprintf(iter%d: LB%.2f, UB%.2f, gap%.2f%%\n, ... k, LB, UB, (UB-LB)/max(1,abs(UB))*100); end fprintf(最优投资方案GT%.2fMW, EC%.2fMW\n, best.cap(1), best.cap(4)); end主循环的顺序很重要永远是先解主问题再把主问题的解传给子问题子问题返回割再回到主问题。不要尝试在子问题里“顺便”返回完整方案让主问题直接用——这样会破坏割平面迭代的数学性质。另外最大迭代次数要设一个硬上限比如50次防止数值异常时无限循环。我在实际项目中见过由于对偶乘子符号错误导致的死循环日志里gap来回跳这种问题第一轮就应该通过打印每一轮的子问题可行性和割参数提前发现。4. 调试实录与避坑指南4.1 子问题不可行问题多半在主问题的容量太小第一次跑通主循环十有八九会遇到子问题不可行——尤其是我上面这个算例初始主问题没有任何割约束时它倾向于把容量压得很低来省钱结果子问题在负荷高峰时段根本平衡不了。解决办法不是去改子问题而是给子问题加松弛变量把“硬平衡”放松成“软平衡”电平衡P_GT P_buy s1 load.e P_EC 目标里加M * (s1 s2 s3)其中 M 是一个很大的惩罚系数比如每单位10^6。然后最小化松弛量之和得到不可行程度 W^k 和对偶乘子生成可行割。这个思路相当于告诉主问题“你这个容量方案在峰值时刻缺了这么多电必须扩容量。”在编码时要注意松弛变量的惩罚系数太大可能引发数值问题太小又会让割失真我一般取比正常目标函数高3个数量级然后观察不可行量的量级再微调。4.2 收敛慢先检查是不是在“原地踏步”迭代曲线最常见的问题是前几轮割添加后LB和UB都在涨但后面gap降得很慢甚至每一轮的结果几乎不变。这通常意味着主问题正在重复生成相似的容量方案。我踩过的一个重要坑是割平面中只包含了电功率平衡的对偶乘子却漏掉了热和冷平衡的乘子导致主问题对热、冷约束的边际信息毫无感知下一轮自然还在同一个坑里打转。后来我把三个平衡约束的乘子都放进割里迭代次数立刻从四十多次降到十几次。另一个有效的加速手段是给主问题加“有效不等式”。比如把所有候选设备的容量上界叠加必须大于三个典型日中最大电负荷和最大冷负荷对应的下限装机量。这种不等式的物理意义很明确不改变问题最优解但能大幅缩小主问题的搜索空间。还有一个小技巧是设置主问题求解器mipgap而不是每次都精确求解到最优这一项在迭代后期尤其省时间。4.3 对偶乘子取不到多半是YALMIP用法细节有问题用YALMIP的dual()函数时我遇到过三种典型情况返回NaN、返回全零、或者返回值的符号跟我预期正好相反。先说NaN通常是子问题求解器返回了错误状态比如linprog因为数值问题没收敛此时要检查子问题约束是否出现inf或NaN。返回全零则要警惕约束是否是可约简的冗余约束YALMIP会先做预处理冗余约束的对偶乘子自然为零。符号问题最常见的是不等式方向比如我用的是 ≤ 约束还是 ≥ 约束对偶乘子的正负含义完全不同建议写代码时固定一套习惯例如所有平衡约束都写成“等式”让乘子带符号统一能省去大量调试精力。另外建议不要试图从dual(con_cap)里直接读“容量约束的影子价格”来当边际容量价值虽然数学上它是但YALMIP返回的乘子可能对应的是经过预处理的等效约束量纲和符号都要仔细核对。我在早期版本里直接用这个乘子构建割平面结果割平面方向反了迭代完全发散。稳妥的做法是用平衡约束的对偶乘子比如电平衡的λ_power来构建割平面——它直接反映了负荷节点功率不平衡的边际成本物理含义和量纲都很干净。4.4 性能优化从8760小时到可实现的规模最后一个实战问题是规模控制。GBD虽然把MILP拆成了“LP 小MILP”但如果子问题直接上8760小时每次迭代就算LP再快几十轮迭代下来等待时间也够喝一壶的。我的做法是先用k-means做典型日聚类把全年负荷聚成8~12个典型日每个典型日按24小时建模并乘以季节权重。这样子问题规模从8760个时段降到几百个时段求解时间从几十秒降到一两秒精度损失通常控制在2%以内——考虑到负荷预测本身的误差这个代价完全可接受。还有两个优化点值得做。第一子问题之间其实没有耦合关系三个典型日的运行变量彼此独立可以并行求解Matlab里用parfor或者把三个典型日合并到一个LP里让求解器一次性解后者在矩阵结构稀疏时往往更快。第二用YALMIP的optimizer对象把子问题编译成“输入参数 → 输出结果”的函数句柄避免每次迭代都重复解析建模型这一步能省掉大量重复建模开销。我记得第一次优化完整个算法跑了十分钟优化一次子问题建模后两分钟出头就全部收敛了。广义Benders分解法最打动我的地方是它把“一个巨大的问题”变成了“很多个小问题每个都有明确的经济学含义和反馈路径”。做这个项目之前我也迷信过“一股脑全扔给求解器”被8760小时的模型折磨过之后才真正理解分解算法的价值。如果你也在做类似的综合能源系统规划我建议第一步不要追求算法的高级变体先把子问题做对、把对偶乘子提取准GBD的迭代自然就会快速收敛。额外提醒一句如果你的模型里加入了非凸约束——比如变压器有载调压的非凸运行域、天然气管道的气压流量方程——GBD是拿不到全局最优的这时候要么做线性化或凸松弛要么换用广义Benders与分支定界嵌套的混合框架这条路就比我这篇博客讨论的内容要深一层了。
返回列表