做综合能源优化规划,模型规模首先是个门槛。设备类型一多,时序场景一展开,园区级电热气耦合模型的变量数轻松过百万,直接抛给通用求解器,连内存都可能顶不住。我在某园区多能互补优化项目里试过好几种路线,最后选中广义Benders分解法(GBD),在Matlab环境里把投资和运行两个环节拆开迭代,求解才从“等不出结果”变成“可以接受”。这篇文章把这个项目的拆解过程完整整理一遍:讲清楚GBD的原理、适应场景、Matlab代码怎么组织,以及我踩过的坑。适合正在做综合能源规划、遇到大规模混合整数规划问题的同行参考。无论你是刚接触分解算法,还是已经写了几版代码但不收敛,应该都能找到点东西。
1. 问题背景与建模思路
1.1 综合能源系统规划到底在算什么
综合能源系统规划,要回答的问题很直白:在给定负荷、能源价格、可用设备和外部网络条件下,选哪些设备装机,容量定多大,以及典型工况下怎么运行。这和我们平时做“运行优化”最大的区别是多了一层“投资决策”。投资决策是0-1变量,运行变量是一堆连续量,还包括储能的充放电状态、机组启停等时序耦合变量。比如模拟项目X里,候选设备包括内燃发电机、燃气锅炉、电制冷机、溴化锂吸收式制冷机、铅蓄电池和蓄热罐,负荷侧有24小时的电、热、冷需求,还要考虑分时电价和天然气价。仅仅一个典型日,如果按小时步长,每条设备出力曲线就有24个变量,再加上各自的启停、爬坡、容量约束,模型一下子就大了。如果做全年8760小时或几十个典型日的联合优化,变量数还要再翻几倍。
规划目标通常是最小化全生命周期折合年成本,包括设备投资年值、运维费、燃料费、购电费。这里“投资年值”是把设备一次性投资按寿命和折现率折算成年费用,否则直接把投资和一年运行费相加会很不公平。约束条件包括每个时刻的电、热、冷功率平衡,上级电网购电上限,天然气供应上限,设备出力上下限,爬坡约束,储能荷电状态守恒,以及设备安装数量约束。整体写出来是一个混合整数线性或非线性规划,非线性主要来自设备效率随负荷率变化、管网压力流量关系、以及部分成本项的非线性。不过大多数工程模型会做分段线性化,最终落到MILP或凸MINLP框架,这就给广义Benders分解留了空间。
1.2 为什么不能把一个巨型MILP直接丢给求解器
如果一个模型只有几百个变量,直接丢给求解器通常没问题。但综合能源规划不一样,问题有两个特点:一是整数变量多,二是时序耦合让约束矩阵变得特别宽。很多求解器的分支定界核心对整数变量规模很敏感,整数变量一多,枚举空间指数上升。即使再用割平面和启发式加速,求解时间也可能从几分钟涨到几小时甚至几天。而且模型里如果还有非线性约束,求解器就需要额外处理,容易陷入局部最优或者数值不稳定。
我当时也试过把完整模型一次性丢给求解器。模型大概包含几千个变量、几百个0-1决策变量,加上二十四个小时段和多种设备耦合,直接求解时内存占用很大,求解过程像卡住了一样。后来我把问题拆开:投资决策变量只有几十个,但运行优化变量上千个。投资部分需要遍历的可能性很多,但是一旦把投资方案固定下来,剩下的运行子问题就是一个规模适中的线性规划,求解非常快。这正是分解算法的核心动机:与其在完整空间里拼命分支,不如先用一部分变量“拍板”,再让另一部分变量去算最优运行成本,用割平面不断反馈修正。这样主问题规模小,分支定界轻松很多;子问题连续可导,可以高效求解。
1.3 广义Benders分解的基本思想
Benders分解的核心思想是“分而治之”。把原问题按变量拆成主问题和子问题:主问题只处理投资变量和一块辅助变量,代表运行成本的上界估计;子问题固定投资变量,求解运行优化,返回两个东西——真实运行成本和对当前投资点的相关灵敏度信息。用这个信息在主问题里加一条割平面,让下一次主问题解出来的投资方案更接近最优。迭代到主问题给的下界与子问题算出的可行上界之差小于容差时,就认为收敛。
广义Benders和经典Benders的差别在于适用范围。经典Benders通常讨论线性子问题,通过线性对偶得到割平面;广义Benders把这一思想推广到凸非线性子问题,通过拉格朗日函数或者KKT条件构造凸割平面。对综合能源规划来说,如果设备效率曲线、潮流方程是凸函数,广义Benders可以直接套;如果做了分段线性化,那就退化到经典Benders,同样适用。这个算法最舒服的场景,就是“少数离散整数变量 + 大量连续变量 + 强耦合约束”的组合,而综合能源规划恰好是这么一个典型结构。
2. 核心原理:从经典Benders到广义Benders的演进
2.1 经典Benders分解的投影视角
理解Benders,最好是站在“投影”角度。原问题可以写成:
[ \min_{x,y} c^T x + d^T y ]
满足约束 ( A x + B y \ge b ),其中x是投资(整数或0-1),y是运行(连续)。把问题投影到x空间,对每个x,定义函数 ( v(x) = \min_y { d^T y: B y \ge b - A x } )。原问题等价于 (\min_x c^T x + v(x))。但(v(x))没有显式表达式。经典Benders的思路是,用一系列下界割去逼近(v(x))。给定一个(\bar{x}),解内部最小化问题,如果可行,得到对偶乘子(\lambda),最优性割是:
[ v(x) \ge d^T y^* + \lambda^T (A x - A \bar{x}) ]
也就是说,用支撑超平面逼近(v(x))。如果不可行,解一个可行性子问题,得到Farkas乘子,加一条可行性割,把当前x从主问题可行域中切掉。主问题里维护一个辅助变量(\alpha)来模拟(v(x)),不断累积割平面,最终收敛。
原理听着简单,工程实现有几个细节要注意。第一,对偶乘子必须取准,线性规划用原模型的对偶变量,但用工具箱时要注意方向。第二,割平面的有效性依赖子问题是最优解;如果子问题数值不干净,割方向会偏,主问题可能震荡。第三,如果子问题中还有整数变量,需要把那些整数变量提前归到主问题,或另做处理。很多新手在这里绕不过弯,总想着子问题也做成混合整数,结果割平面无法直接从LP对偶得到,算法自然不稳定。
2.2 广义Benders的核心:用非线性对偶构造割
广义Benders解决的问题是,子问题里出现非线性约束和凸非线性目标时怎么办。经典Benders里的线性规划对偶依赖强对偶,换成凸优化后,只要满足斯莱特条件,强对偶同样成立。此时最优性割可以通过拉格朗日函数构造。假设子问题为:
[ \min_y f(x,y) \quad \text{s.t.} \quad g(x,y) \le 0 ]
固定(\bar{x}),求解得到最优乘子(\lambda)和最优值(v(\bar{x})),割为:
[ v(x) \ge v(\bar{x}) + \lambda^T \nabla_x g(\bar{x},y^*) \cdot (x - \bar{x}) ]
这与非线性规划里的支撑超平面类似。综合能源系统里,燃气轮机负荷率-效率曲线、储能损耗函数通常是非线性的,只要它们关于决策变量凸,整个子问题的值函数就是凸的,广义Benders割成立。
需要强调,虽然名称带“广义”,它并没有解决非凸问题。若子问题非凸,割平面可能把不可行区域当作可行,也可能低估最优值。所以用GBD之前,一定要做凸性检查。我在模拟项目X里把天然气管道压降、设备非线性效率都先做了凸松弛或者分段线性化,保证每个子问题都是凸的。如果有些约束天然是非凸(比如含双线性项的换热网络),GBD会很难受,这时候要么再套一层凸化技巧,要么换别的算法。
2.3 收敛性、最优性割与可行性割的关系
Benders迭代的上下界非常清晰:每次主问题求解得到一个投资方案和它的目标值,由于主问题只用了部分割平面近似(v(x)),主问题目标值是原问题的一个下界(如果是最小化);把投资方案代回子问题,得到可行运行成本和真实总成本,这是一个上界。随着割平面增多,下界逐渐上升,上界逐渐下降,最终gap收敛到0。实际工程里不需要等到绝对0,通常设置相对容差为1%或0.5%,迭代次数控制在几十次以内。
可行性割的作用是把“不可行的投资方案”逐步排除。比如某台制冷机容量选小了,在夏季高峰时段无法满足全部冷负荷,子问题不可行。此时如果没有可行性割,主问题下次还会选同样的小容量,于是陷入死循环。加了Farkas割之后,主问题会知道“容量再小就不行”,从而转向更大的容量组合。很多时候,项目里最让人头疼的不是最优性割,而是可行性割写错了方向,把本来可行的方案也切掉了。判断割有没有问题,可以用一个简单办法:把当前x代回完整模型,如果完整模型可行,则可行性割一定是错误的;如果不可行,再看它是否把邻近可行解也排除。
3. Matlab代码实现全过程
3.1 整体工程结构和数据结构
我一般把一个GBD工程拆成四个文件:主脚本负责数据输入和迭代控制;主问题函数返回投资方案和下界;子问题函数返回运行成本、乘子、可行性状态;割管理函数负责存储和组装割平面。这样调试的时候可以单独跑子问题,不必每次都走完整迭代。
数据层面,最重要的是把模型参数统一到几个结构体里。比如mp_data存设备参数、候选数量、投资成本、寿命;sp_data存负荷、能源价格、设备运行效率、储能参数;cuts存割平面系数。决策变量不推荐在主问题里用全局变量,而是通过函数参数传递,否则多个算例来回切换时容易出错。我见过不少代码把所有变量都塞到工作区里,改一个参数就要手动清理,非常容易埋雷。
下面是主脚本的骨架:
function [x_opt, obj_opt, log] = gbd_main(mp_data, sp_data, opts) % 初始化 x0 = initial_investment(mp_data); cuts = struct('Aopt',{},'bopt',{},'Afeas',{},'bfeas',{}); LB = -inf; UB = inf; log = []; for iter = 1:opts.max_iter % 1. 解主问题 [x_bar, alpha_bar, LB] = solve_mp(cuts, mp_data); % 2. 固定投资,解子问题 [Q, lambda, feasible] = solve_sp(x_bar, sp_data); if feasible UB = min(UB, investment_cost(x_bar, mp_data) + Q); cuts = add_optimality_cut(cuts, x_bar, Q, lambda, sp_data); else cuts = add_feasibility_cut(cuts, x_bar, lambda, sp_data); end gap = (UB - LB) / abs(UB); log(iter).x = x_bar; log(iter).LB = LB; log(iter).UB = UB; log(iter).gap = gap; if gap <= opts.tol break; end end x_opt = x_bar; obj_opt = UB; endadd_optimality_cut和add_feasibility_cut返回新的割集,主问题里再统一加载到约束集合中。注意每次迭代产生一条割,但如果割太接近或重复,建议做一次“割去重”,保留对当前迭代有用的几条,避免主问题约束越来越多,拖慢求解。
3.2 主问题实现:投资决策与成本逼近
主问题变量包括0-1安装变量和辅助变量alpha。目标函数为投资年成本加alpha。约束除了设备最大安装数量、容量上下限等,还有一个“强制选择”约束,比如至少安装一个供能设备,否则子问题没有可用电源。所有最优性割和可行性割都以约束形式加入。
用Matlab建模时,我习惯先定义符号决策变量,再组装约束,最后调求解器。代码大致是:
function [x, alpha, LB] = solve_mp(cuts, mp_data) n_units = mp_data.n_units; x = binvar(1, n_units); alpha = sdpvar(1, 1); Constraints = []; Constraints = [Constraints, sum(x) >= mp_data.min_units]; % 最小设备数 if ~isempty(mp_data.max_units) Constraints = [Constraints, sum(x) <= mp_data.max_units]; end % 最优性割 for k = 1:length(cuts) if ~isempty(cuts(k).Aopt) Constraints = [Constraints, alpha >= cuts(k).Aopt * x' + cuts(k).bopt]; end end % 可行性割 for j = 1:length(cuts) if ~isempty(cuts(j).Afeas) Constraints = [Constraints, cuts(j).Afeas * x' + cuts(j).bfeas <= 0]; end end Objective = mp_data.inv_cost * x' + alpha; ops = sdpsettings('solver', mp_data.mp_solver, 'verbose', 0); optimize(Constraints, Objective, ops); x = value(x); alpha = value(alpha); LB = value(Objective); end需要解释的是,主问题里的alpha是一个连续的运行成本代理,它不会被真实运行约束限制,只能靠割平面逐步收紧。所以即使主问题目标值很低也不要慌,那只是下界。迭代过程中,alpha可能会出现负值,只要割平面还没覆盖到,这是正常的。为了稳定,可以对alpha加一个下限,比如alpha >= 0或一个合理的大负数,防止求解器在无界区域乱跳。
主问题求解器要选支持0-1整数规划的MIP求解器,并设置相对MIP gap。因为主问题本身也常常需要分支定界,如果它自己都解不准,外层GBD的下界就会上下抖动。我一般把主问题的求解gap设得比外层gap更严格,比如外层容忍1%,主问题就设0.1%。否则外层收敛很难。
3.3 子问题实现:运行优化与对偶乘子提取
子问题固定投资变量x_bar后,需要构造设备可用集合。比如一台设备如果没有被选中,它在这一时段的所有出力都必须强制为0;如果选中,则出力在0和额定容量之间。最直接的做法是把x_bar乘到容量上界上。注意,设备如果没安装,那么它的变量仍然存在,只是被约束成0,这会让约束矩阵大一些,但好处是代码不用动态删变量,索引逻辑简单。
子问题里是所有运行变量(连续量),可以进行LP求解。对于纯线性子问题,目标函数是运行成本最小化,约束包括各时刻电热冷平衡、设备出力和爬坡、储能状态。示例代码如下:
function [Q, lambda, feasible] = solve_sp(x_bar, sp_data) T = sp_data.T; n = sp_data.n_units; p = sdpvar(T, n, 'full'); soc = sdpvar(T+1, sp_data.n_storage, 'full'); qb = sdpvar(T, sp_data.n_storage, 'full'); % 充/放热功率 Constraints = []; % 设备出力上下界:x_bar为1则上限为容量,为0则上限为0 for k = 1:n Constraints = [Constraints, 0 <= p(:,k) <= sp_data.cap(k) * x_bar(k)]; end % 电平衡 Constraints = [Constraints, sum(p,2) + sp_data.grid_buy(:) == sp_data.elec_load(:) + ...]; % 热平衡、冷平衡类似 % 储能约束 Constraints = [Constraints, soc(2:T+1,:) == soc(1:T,:) + ...]; Objective = sp_data.gas_price * sum(p(:,1)) + sp_data.elec_price * sum(grid_buy) + ...; ops = sdpsettings('solver', sp_data.sp_solver, 'verbose', 0); result = optimize(Constraints, Objective, ops); if result.problem == 0 Q = value(Objective); feasible = true; % 提取耦合约束的对偶乘子 lambda = dual(Constraints, 1); % 具体哪个约束取决于建模顺序 else Q = inf; feasible = false; lambda = dual(Constraints, 1); % 不可行时提取Farkas乘子 end end这段代码是示意,真实工程里建议把约束分成组,给每组命名,然后通过dual获取对应乘子。不要贪图方便把所有约束放在一个大数组里,后面提取乘子会非常痛苦。特别是耦合约束(比如投资变量出现在容量约束中的那一条),它的乘子正是最优性割需要的系数,权重极高。
如果子问题不是纯LP,而是凸非线性规划,那么dual可能对应的是拉格朗日乘子,割平面形式要相应改成梯度和函数差值的形式。用Matlab的优化工具箱处理非线性问题时,要拿到解析乘子并不容易,这时候更推荐借助接口直接建模成带对偶的凸问题,或者用线性化后的近似子问题。为了省事,我在模拟项目X里把子问题全部处理成LP,但同时保留了多组“运行模式”,模拟非线性效率对成本的影响。
3.4 交替迭代、收敛判定与割集管理
GBD迭代控制可以写得非常朴素:while循环,每次调主问题和子问题。但工程上要注意几个实际问题。第一,每次主问题求解后得到的x_bar不一定是全局最优的投资方案,因为主问题本身是非凸的MIP,求解器只能保证在给定MIP gap下找到“近似最优”。这样下界可能不稳定,建议记录最近若干次的下界,取最大值作为当前LB。第二,如果子问题可行且目标值比当前UB小,才更新UB;否则UB保持不变。第三,割平面数量要控制。每轮加一条最优性割或可行性割,几十轮后割集可能很庞大,而很多割已经不再起作用。一个简单的办法是,每5轮做一次“清理”:只保留最近10条割和那些在上一轮主问题中起作用的割。这个操作在模型小的时候收益不明显,模型大了,割约束多起来之后,主问题的求解时间会明显上升。
我还习惯把每轮的主问题方案和上下界记录到log里。收敛后画两条上下界曲线,一眼能看出算法是不是在振荡。如果曲线锯齿严重,多半是割方向不稳定或求解器gap太大。如果曲线一直平着不动,可能在可行性割和主问题之间循环,需要检查是否生成了重复割。
可以看一下典型的迭代记录,格式大致如下:
| 迭代轮次 | 下界LB | 上界UB | 相对gap | 当前选中的关键设备 |
|---|---|---|---|---|
| 1 | 120.5 | 260.3 | 53.7% | 内燃机+燃气锅炉 |
| 5 | 198.2 | 245.8 | 19.4% | 内燃机+电制冷机 |
| 10 | 238.7 | 249.2 | 4.2% | 内燃机+蓄热罐 |
| 15 | 246.1 | 250.0 | 1.6% | 内燃机+吸收式制冷机 |
| 21 | 248.9 | 250.3 | 0.6% | 内燃机+吸收式制冷机+蓄热罐 |
这种表格对判断算法收敛行为很有帮助。如果gap一直降不下去,可以回查第5轮和第15轮方案差异,看是不是在几个方案之间反复横跳。
3.5 参数设置与结果输出
参数方面,我比较常用:外层容差1%,最大迭代次数100,子问题容量约束里的小量epsilon取1e-6,主问题MIP gap设为0.1%。这些值不是拍脑袋,而是根据模型规模和收敛曲线调的。容差太紧,比如0.01%,迭代次数可能翻倍;太松,得到的投资方案可能还不是最优。我建议第一次跑一个算例时用1%容差快速验证逻辑,再收紧到0.1%看结果变化。结果输出建议包含三个部分:最优投资方案(哪些设备、什么容量)、运行成本明细(购电费、燃料费、运维费)、以及与完整模型直接求解的对比误差。没有对比,很难判断GBD实现是否正确。
我在模拟项目X里最终得到的结果是:内燃发电机和蓄热罐被选中,燃气锅炉作为辅助热源,电制冷和吸收式制冷都保留。迭代大约40轮收敛,上下界gap到0.8%。作为对比,完整模型直接求解要花一个多小时,GBD大约十分钟左右。这个收益模型规模越明显。
4. 实操中的踩坑与排查技巧
4.1 主问题不可行或者下界为负无穷
主问题不可行最常见的两个原因:一是可行性割写反了,把本来可行的投资方案全部排掉;二是初始主问题缺少必要的强约束,比如没有最小安装数量约束,导致求解器认为不装任何设备最省钱,但子问题又没法满足负荷。解决办法是在主问题里加入逻辑约束,比如“每个时段至少有一个设备能出力”,或者直接用贪婪启发式生成一个可行初始方案。下界为负无穷则往往是因为alpha没有下界,而割平面还没覆盖到那个区域,求解器给出了一个特别小的目标值。给它加一个alpha >= -1e6的约束就好很多。这个下限不会影响最终结果,因为真实运行成本不可能是负无穷。
4.2 子问题不可行时,Farkas乘子为什么提取不对
子问题不可行,需要生成可行性割。很多人在这一步卡住,主要问题是乘子方向搞反。对一个不等式约束Ax + By <= b,Farkas乘子应该有特定的符号。不同建模语言的duals定义方向不一样,有的返回的是松弛变量乘子,有的是拉格朗日乘子,直接拿去生成割会导致割的方向相反。建议做一个最小测试:构造一个简单的两变量LP,手动计算对偶乘子,再去跟代码里提取的数值对比,确认符号。我自己的经验是,别在大型模型里直接调试可行性割,先用一个三设备的小模型把方向试对,再扩规模。否则你永远分不清是算法问题还是乘子方向问题。
另外,提取不可行乘子时,很多求解器需要先单独求解一个“可行性恢复问题”,而不是直接读原约束的dual。如果直接在不可行模型上读乘子,可能会得到一个空值或错误值。有时候更稳妥的办法是,把运行子问题改成“满足约束的最大化松弛变量为负”的形式,即加上非负松弛变量,目标是最小化松弛和,然后从这组松弛约束提取乘子。这样可行性割语义清楚,也容易调试。
4.3 为什么GBD收敛后,投资方案代入原模型反而不满足约束
这个问题很多见,原因多半是子问题建模时没有完全复刻原模型的约束,尤其是那些跨时段耦合约束和整数启停约束。比如原模型里有最小开机时间、停机时间约束,如果你在子问题里把它们简化掉了,那子问题求出的“可行运行成本”对原模型来说其实是不可行成本。主问题又拿这个信息去加割,自然会把最优解切错。解决办法是把所有运行耦合约束都放进子问题,除非你能严格证明某些约束对目标没影响。宁可子问题变量多一点,也不能漏约束。
还有一种可能是割平面的线性化范围问题。对凸子问题,割平面是全局有效的;但如果子问题非凸,割只在当前点附近有效,外推时会把不可行区域误判为可行。这种情况下,GBD给出的上界可能低于真实最优值,而你还不容易察觉。所以在落地前,我还是建议大家把模型凸性检查放在第一位:如果子问题是非凸的,要么重新建模成凸问题,要么换外逼近的变形算法,并加上反凸割。
4.4 收敛慢、上下界锯齿怎么办
收敛慢的核心原因,是割平面信息不够“锐利”。常见处理手法包括:设置更紧的主问题MIP gap、给alpha加下界、对割做归一化处理。归一化很重要:割系数里的乘子可能数值差异很大(比如电压约束乘子动辄几百,容量约束乘子只有零点几),直接把这种割加进主问题,会劣化数值条件。我通常会对割系数做缩放,或者用平衡系数让主问题约束矩阵的尺度尽量接近。另一个实用技巧是采用多割:不是把整个子问题合并成一条割,而是把子问题按能源种类或时段拆成几个独立子问题,每个产生一条割。这样一次迭代能向主问题传递更多信息,通常能显著减少迭代次数。
如果锯齿严重,还可以考虑“空调制”或稳定化策略。简单说就是限制每次主问题投资方案相对上一次的变化,不让Benders在几个候选方案之间来回跳。这些技巧在常规文档里很少讲,但对综合能源这种动辄几十轮迭代的模型,收益特别明显。我自己最后用的方案是:前10轮不做稳定化,让算法自由探索;10轮之后,把主问题加上一个基于历史方案的正则项,相当于用较平滑的方式逼近最优。
下面是几个常见问题的速查:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 主问题不可行 | 可行性割方向错误 | 用小算例验证乘子符号 |
| 子问题不可行但乘子为空 | 直接在不可行模型上读dual | 改用可行性恢复子问题提取乘子 |
| 上下界gap不降 | 割平面重复或方向偏 | 割去重、割归一化、多割策略 |
| 收敛结果与原模型不一致 | 子问题漏约束 | 逐项核对原模型运行约束 |
| 迭代次数过多 | alpha下界太松 | 给alpha加合理下限并收紧主问题gap |
4.5 用完整模型校验GBD结果
无论代码多自信,都必须做交叉验证。最直接的验证方法是,把GBD收敛后得到的投资方案固定下来,调用完整模型求解运行子问题,看它的目标值与GBD子问题结果是否一致。如果一致,说明子问题建模正确;如果偏差大,说明可能漏了约束或乘子提取有问题。我还会做第二层校验:把完整模型直接丢给求解器,用更长的时限或更小的MIP gap求解,看最优投资方案是否与GBD一致。如果模型太大实在解不动,至少选一个较小的时间窗口(比如一天)来验证。模拟项目X里,我用48小时典型日做了全模型对比,误差在1%以内,才敢把结果拿到后续的敏感性分析里用。
5. 经验总结与扩展方向
5.1 算法选型:什么时候选GBD
和分支定界、外逼近、拉格朗日松弛相比,GBD不是万能的。它适合投资变量少、运行变量多、且运行子问题是凸规划的场景。如果整数变量本身很多,比如上百个设备的逐时段启停决策都要放入主问题,主问题会迅速膨胀,GBD的优势就小了。此时可以考虑把启停也提到主问题,但要做更谨慎的变量划分。如果子问题强非凸,GBD的割不再全局有效,更可靠的方案可能是空间分支定界或外逼近的改进版。总之,选型不是越高端越好,而是匹配问题结构。综合能源规划绝大多数情况是“投资离散、运行连续”,所以GBD是很有性价比的起点。
5.2 代码层面可以继续扩展的方向
我做GBD时其实也叠加了一些加速技术:一是多割Benders,把子问题按能源种类拆成电、热、冷三个独立子问题,每个都返回一条割,主问题一轮可以吸收三个方向的信息,迭代次数从40多轮降到20轮出头;二是初始可行方案,先用启发式求一个“看起来合理”的投资方案作为第一轮子问题输入,这样上界一开始就比较高,能尽早剪掉很多主问题候选;三是热启动,主问题求解器每次从上一轮的整数解附近开始,减少重复分支。这些在Matlab代码里都不难实现,但对大型模型特别有效。
5.3 后续还可以怎么改造
如果想把这个工程用于实际规划,我建议从三点入手:把典型日扩展为多场景和多时段聚类,用场景削减技术降低规模;把碳约束写成碳排放总量限制或碳价形式,加入主问题;把网架潮流约束纳入子问题,从能量平衡升级到电网和气网的N-1校验。这些改造都会增加模型复杂度,但只要子问题保持凸性,GBD框架可以直接复用。我在模拟项目X后面又加了一个碳配额约束,主要变化只在子问题里多了一组碳排放累计约束,主问题结构基本没动。这个工程化弹性,也是我后来继续用它做规划工具的原因。