1. 两阶段鲁棒优化到底在解决什么问题
接触两阶段鲁棒优化这个方向之前,我一直在用传统的确定性优化跑微网调度模型,也就是把光伏出力、负荷曲线都当成已知量,输入一个固定的预测值,求解器算出机组出力计划。这个东西做论文仿真确实很顺手,但放到实际场景里就发现了一个尴尬的问题:预测值只是众多可能场景中的一种,万一实际光照突然变化或者负荷暴涨,模型给出的调度方案根本兜不住底。
两阶段鲁棒优化解决的就是这个"不确定性"问题。它的核心思路可以类比成打仗之前的排兵布阵:第一阶段是在还不知道敌方具体行动的情况下做决策,这个决策得先定下来,没法等情报全部清楚了再改;第二阶段则在对手行动暴露后,根据实际发生的情况做调整,用最小的代价来补救。对应到微网上,"第一阶段决策"是日前阶段的机组启停、储能充放电计划这类必须在知道实际风光和负荷之前就定好的东西,"第二阶段决策"则是在不确定性全部显现后,微网通过调整机组出力、储能实际放电量来保证系统安全运行的实时调度动作。
有一点需要先讲清楚,"鲁棒"这个词在优化领域里指的不是"模型跑起来不容易崩",而是"在最恶劣情况下系统依然能正常运行"。两阶段鲁棒优化本质上是一个min-max-min形式的博弈:外层最小化综合成本,中层(不确定性变量)选择让成本最大的那个场景(也就是最坏场景),内层再针对这个最坏场景做最小化调整。这就和随机规划不同了,随机规划给各个场景分配概率然后求期望,鲁棒优化不关心概率,它只盯着最坏情况兜底。
刚接触这个模型的读者很容易把"最坏场景"理解为光伏设为0、负荷设为最大值就是一个场景。实际没这么简单,因为场景里的多个不确定性变量是要满足实际约束的,比如光伏出力受天气影响有上限,负荷扰动范围有边界,这些约束交织在一起,最坏场景往往不是一个边界上的简单组合。这也是两阶段鲁棒优化程序写起来要比普通优化模型复杂得多的根本原因。
2. 微网模型:为什么选择它作为鲁棒优化的载体
2.1 微电网的结构与核心设备集
微电网之所以成为两阶段鲁棒优化的经典应用载体,是因为它的设备集合天然包含了"需要提前定计划"和"运行中需要实时调整"两类决策。一个典型的中压微电网包含燃气轮机或柴油发电机、储能电池、光伏阵列、负荷,以及和外部大电网的联络线。光伏出力是典型的不确定源,负荷也有很强的随机波动,但储能和可控机组在运行中可以快速调整出力。
这类系统的调度问题可以拆成两个时间尺度。日前阶段决定机组的启停状态、储能的充放电计划、以及从网外购电的中长期计划,这些决策一旦做了,当天基本不会再改。日内阶段则是在实际运行中,根据光伏和负荷的实测值,控制器在几分钟到几十分钟内调整储能出力、柴油机组出力,以及跟大电网的实时交换功率,维持系统功率平衡。
这里有个做程序时的常见误区:把日前决策和日内决策写成两个独立的模型,先算日前,再把日前结果当参数丢进日内模型里优化一次。两阶段鲁棒优化是要求"跨阶段一体化考虑"的——日前决策定下来之后,日内阶段要针对所有的可能场景(尤其是在最坏场景下)都可行,而不是只针对某一个特定场景可行。这意味着阶段之间是嵌套关系,不是串联关系。
2.2 不确定性集合怎么建模
在搭建两阶段鲁棒微网模型时,不确定性建模是最关键的步骤。常见的方式是把光伏出力和负荷的预测值作为基准,加入一个上下浮动的范围,再用箱式集合来描述:
光伏出力:P_pv = P_pv_forecast + ΔP_pv,其中ΔP_pv的绝对值不超过预测值乘以扰动比例系数。 负荷水平:P_load = P_load_forecast + ΔP_load,同理受扰动边界约束。
箱式集合的好处是简单直观,参数少,但缺点是它假设所有不确定源同时取到最坏值,这在实际系统中过于保守。工程上更常用的是引入一个预算参数Γ来限制总的不确定度偏移量,也就是所有不确定变量的归一化偏移之和不超过Γ。这个Γ也叫"鲁棒控制参数"、"不确定度预算",它的取值范围通常在0到总不确定变量个数之间。
预算参数的意义在于提供了一个"保守度旋钮"。Γ取0时模型退化为确定性模型,完全信任预测值;Γ取满时模型考虑所有不确定性同时爆发的最坏情况,保守度最高。实际应用中可以扫描不同的Γ值,画出一条"成本-保守度"权衡曲线,供决策者选择。
这段内容实操中会直接影响对偶模型约束的复杂度,我建议在写程序前先把不确定性集合的代数形式完整推导出来,尤其注意预算约束的不等式方向。
2.3 微网两阶段鲁棒调度模型的完整表达
微网的日前-日内两阶段鲁棒调度模型可以写成如下形式:
第一阶段(日前):
- 目标是最小化机组启停成本、储能固定调度成本、以及第二阶段对所有可能不确定场景下的期望/最大运行成本之和。
- 约束包括:机组启停逻辑约束,储能充放电状态互斥约束,联络线购电上限约束等。
第二阶段(日内):
- 针对给定的第一阶段决策和不确定变量取值,调整机组出力、储能充放电功率、弃光量、切负荷量,最小化即时运行成本。
- 约束包括:功率平衡约束、机组出力上下限及爬坡约束、储能SOC动态方程及容量约束、联络线功率约束。
写这个模型时需要注意两个细节。第一个是储能SOC方程里带时间耦合,不管第二阶段在哪个场景下,SOC状态要满足递推关系;第二个是我第一版程序里很容易漏掉的:第二阶段约束中的不确定性变量可以出现在功率平衡方程里,但不可以出现在第一阶段约束里,否则模型就变成了完全耦合的,无法用标准的分解算法求解。
还有一个逻辑需要明确:如果第二阶段模型不可行怎么办?实际工程处理方式是引入失负荷变量或弃光变量作为松弛变量,对应非常高的惩罚系数。这种做法虽然让模型"任何决策都能兜底",但惩罚系数设置不合理时会出现假的可行解——惩罚成本比购电成本低,模型宁可切负荷也不去买电,这个问题我在第5节会再展开讲。
3. C&CG算法:求解两阶段鲁棒优化的核心利器
3.1 主问题与子问题的分解逻辑
C&CG(Column-and-Constraint Generation,列与约束生成)算法是目前实现两阶段鲁棒优化的主流方法。它不像Benders分解那样通过不断添加割平面来逼近,而是每一步迭代都把一个"最坏场景"的具体实现当成新的列和约束加入主问题,使得主问题的规模逐步扩展,逼近原问题的最优解。
标准的算法流程是这样:
- 初始化:给定一个不确定性场景(通常取预测值,或边界值)。
- 主问题:在已经发现的有限个场景集合上求解第一阶段决策和对应的总成本,得到可行解和成本下界LB。
- 子问题:固定第一阶段决策,求解内层最小化和外层最大化问题,找到当前决策下最坏的不确定性实现,并把最坏成本作为上界UB。
- 收敛判断:如果UB和LB之间的相对间隙Gap小于设定阈值(比如0.01),则终止迭代,输出最优决策;否则把新的最坏场景对应的变量和约束加入主问题,回到第2步。
在程序实现过程中,主问题的规模会随着迭代次数增加而变大。每轮迭代都加入一组新的第二阶段变量和约束,这些变量和约束对应不同的不确定性场景实现。做仿真时常看到有人在迭代30轮之后主问题已经有上万条约束,求解速度明显变慢,这是正常的。合理的Gap设置可以避免无意义的多余迭代,我一般取0.01,工程上0.05也够用。
3.2 子问题求解:双层结构不能直接求解
子问题最麻烦的地方在于它是一个"max-min"双层结构:外层不确定性变量在找最坏场景,内层运行变量在做最小化调度。绝大多数商业求解器(比如Gurobi、CPLEX)不支持直接求解这种双层模型,所以要做转换。
标准做法是利用强对偶理论(或KKT条件)把内层的最小化问题对偶成一个最大化问题,这样"max-min"就变成了一个单层的"max-max",可以直接合并成最大化问题。对偶转换有严格的条件:内层问题是线性的(或者凸的)、满足强对偶条件。这也是为什么很多学术论文里的微网模型要刻意简化——储能和机组启停在第二阶段通常是固定值或线性化表示的,目的就是为了保持第二阶段问题线性。
从实操角度讲,我建议在第一天写代码的时候就把第二阶段模型严格限制为线性规划。不要一开始就上混合整数非线性,否则对偶推导和求解器配置的复杂度会让你怀疑人生。等线性版本跑通了,再逐步放宽模型假设。
3.3 对偶子问题中的KKT条件和大M法
除了纯对偶方式,还有一种常见的转化思路是直接在子问题里引入KKT条件,把内层最小化问题的"最优性条件"作为约束加到子问题中。KKT条件包括:原始可行性约束、对偶可行性约束、互补松弛条件、拉格朗日函数梯度为零的条件。
其中互补松弛条件是非线性约束,没法直接丢给Gurobi这类求解器处理。工程上的做法是用大M法将互补松弛条件线性化:引入一个二进制变量,配合一个足够大的M常数,把两个约束的乘积关系拆成两组线性约束。
大M法的难点在于M的选取。M选小了会截掉可行域,选大了会造成数值病态。实操技巧是根据模型参数的取值范围,把M设置成"可能取到的最大绝对值乘上10到100倍"。比如某个对偶变量理论上最多取到1,那M取100就够。不过这个取值范围推导需要结合具体约束来分析,每个问题不一样。
我在论文复现中踩过一个具体坑:一开始对A、B两组互补条件用了同一个M值,结果子问题松弛出了一些不合理的对偶变量取值。后来把M分开设置,一组取100,一组取500,问题就消失了。求解器的数值容差在这种问题上很敏感,大M的值宁大勿小,但也不要大到离谱。
还有一点值得说,如果用的是YALMIP或CVX这类建模工具,它们内置的"对偶化"功能在某些场景下会自动处理这类问题,但生成的模型形式不够直观,调试困难。如果我需要在论文里解释清楚求解逻辑,更推荐手动推导对偶形式,再显式地建模子问题。
3.4 为什么我推荐先用商用求解器而不是开源求解器
两阶段鲁棒优化程序里,主问题是混合整数规划(MIP),子问题是带对偶变量的线性规划(LP)。主问题的求解效率直接决定了整个算法的迭代速度,而像CBC、GLPK这类开源求解器处理大规模MIP的能力明显弱于Gurobi或CPLEX。学术用途的Gurobi许可证通常免费,所以优先推荐它。
求解器配置上值得注意的有几点。一是MIPGap参数:主问题的求解精度不必太高,MIPGap设在0.001到0.0001之间就行,我在快速验证时常用0.0001;二是在子问题求解后要检查求解状态,如果子问题无界,通常说明对偶推导或者大M设置出了问题,需要从模型层面排查;三是Gurobi在多线程下的表现要远好于单线程,但在C&CG主问题规模不大时,开8个线程以上的边际收益递减,不必盲目堆线程数。
4. 从零搭建两阶段鲁棒优化程序:核心模块拆解
4.1 代码框架:模块划分
我习惯把一套两阶段鲁棒优化程序分成四个模块:
- 数据输入模块:读取系统参数、设备参数、预测曲线、不确定性集合参数,统一放到配置类里。
- 主问题构建模块:负责创建主问题模型、添加第一阶段变量和约束、维护不断膨胀的场景集合对应的变量与约束。
- 子问题构建模块:负责构建对偶化后的单层子问题,输入第一阶段的固定决策,输出最坏场景以及对应的第二阶段成本。
- C&CG循环控制模块:负责初始化场景、循环调用主问题与子问题、计算Gap、判断收敛、输出结果。
这种模块化设计的好处是每个部分都可以独立调试。尤其子问题和主问题分开写之后,你可以先用一个假的第一阶段决策来测试子问题是否能正常求解,而不是等到整套算法跑完才发现问题是出在子问题构建上。
4.2 主问题构建的伪代码骨架
def build_master_problem(config, scenarios): """构建C&CG主问题""" mp = Model("Master_Problem") # 第一阶段变量 x_start = mp.addVars(n_generator, vtype=GRB.BINARY, name="start") x_soc = mp.addVars(n_battery, T, name="soc") # 储能SOC计划 # 对每个已发现的场景,添加第二阶段变量 for s, scenario in enumerate(scenarios): y_g = mp.addVars(n_generator, T, name=f"y_gen_{s}") y_pgrid = mp.addVars(T, name=f"y_grid_{s}") y_pbat = mp.addVars(n_battery, T, name=f"y_bat_{s}") # 引入该场景对应的耦合约束 for t in range(T): mp.addConstr( sum(y_g[g, t] for g in range(n_generator)) + y_pgrid[t] + sum(y_pbat[b, t] for b in range(n_battery)) == scenario["load"][t], name=f"balance_{s}_{t}" ) # 储能SOC与日前的关联 for t in range(1, T): mp.addConstr(y_soc[s][t] == y_soc[s][t-1] + y_pbat[s][t] * dt) mp.setObjective(...) # 第一阶段成本 + 最大场景成本加权 return mp代码片段省略了储能SOC变量在不同场景间如何关联的细节,但大致流程就是这样。每轮迭代在新加入的场景索引s下生成一组新的第二阶段变量,这些变量的约束参数可以不同(因为场景不同),但第一阶段变量是共享的。
这里我需要强调一个关键点:不同场景的第二阶段变量之间不能有任何交叉约束,否则会把"每个场景单独可行"的要求错误地强化成"所有场景同时满足同一个变量取值"的更强条件,导致结果过度保守。我见过有人把储能SOC场景间写成同一个变量,结果模型从"鲁棒"变成了"拍脑袋",运行成本特别高。
4.3 子问题构建与对偶化步骤
子问题代码的核心工作在于把第二阶段最小化问题转成对偶形式。假设第二阶段问题是min c^T y(受A(x) y + B u <= b约束等),引入对偶变量λ后,对偶问题是max b^T λ - (某部分关联x的项),受A^T λ <= c,λ >= 0约束。
实际写代码时,我建议先用纸笔把对偶问题完整推导出来,再逐条写到代码里。常见的推导错误包括:等式约束对应的对偶变量没有自由符号限制、目标函数中常数项漏掉、对偶变量的符号方向写反。这些错误在数值上只会在Gap不收敛时才暴露出来,调试起来非常痛苦。
我把这部分的推导经验总结成三条:
- 第二阶段目标如果是min cost,对偶后的目标就是max something,符号要检查两遍。
- 功率平衡等式对应的对偶变量是没有符号限制的(free variable),不要写成非负约束。
- 不确定性变量出现在约束右侧时,其对偶项会连带到目标函数中,这个环节最容易被忽略。
4.4 多面体不确定性集合的对偶表达
如果不确定性集合是多面体(比如带预算约束的箱式集合),在子问题里最大化部分变成对不确定变量和原对偶变量的乘积项求最大值。这是个双线性项,没法直接线性求解。
处理方式是在子问题里做一次线性化:因为不确定性变量的可行域是多面体,最大化一个双线性项等价于在极值点取最优。如果不确定性变量只出现在约束右侧,线性化后可以转化为一组针对顶点场景的枚举或等价线性表达式。
实操中,如果不确定性集合比较小(比如只有光伏和负荷两个不确定源),直接枚举顶点也是一种简单可行的办法,虽然理论效率不如全对偶方式。我在小规模测试模型里直接枚举4到8个顶点,效果相当稳定,推荐新手先用这个方式验证整体框架,再过渡到完整的对偶子问题。
5. 实操过程中我踩过的坑与调优心得
5.1 子问题不可行或对偶无界
这是最常见的问题。我第一版模型在跑子问题时,求解器直接报"Infeasible"。排查方向有两个:第一,第二步可行性没问题,可能是第一步决策太激进(比如储能SOC越限了),模型本身没有留裕量;第二,更隐蔽的问题是子问题里某些约束变量没接上,导致变量完全自由,产生了一个看似不可行其实是模型的逻辑漏洞。
调试办法是:在子问题脚本里,把第一阶段决策变量替换为一个人工构造的可行解,比如所有机组出力取预测负荷的50%、储能出力为0。如果这样还不可行,那说明模型本身有约束冲突,和鲁棒算法无关。这个技巧在找bug的时候极其高效。
5.2 惩罚系数陷阱
前面提到的失负荷惩罚系数,我一开始取了很高的值(10000元/千瓦时),结果主问题的优化器总倾向于花这个代价去调整储能计划,反而不去利用更现实的购电空间。后来我做了扫描:把惩罚系数当成超参数,从1000到50000逐步试验,观察最优成本曲线。当系数大到一定程度后成本不再变化,那个点附近的取值才是合适的。
工程里面有个经验值可以参考:失负荷惩罚系数至少要高于最贵的机组发电成本10倍以上,但不能高到扭曲模型数值稳定性(比如超过1e6)。
5.3 Gap收敛慢的问题
如果C&CG迭代20轮以上Gap还在3%以上徘徊,优先怀疑两件事:第一个是主问题MIPGap设置太松,导致加进子问题里的决策x不够准,最坏场景判断失真;第二个是子问题对偶模型构建有微小错误,导致UB偏小或LB偏大。
我在一个复现案例里发现,把主问题MIPGap从1e-4调到1e-6后,Gap在8轮后就降到0.1%以下。所以要分清楚:C&CG外层Gap与求解器内层MIPGap不是一回事,前者是算法迭代的收敛判据,后者是求解器求解主问题的精度。两层精度要求要协调。
5.4 场景遗漏与初始化场景的选择
初始化场景如果直接取预测值,前几轮迭代时主问题相对乐观,子问题暴露出更坏场景后,Gap会先上升再下降,这是正常现象。如果初始化直接取顶点极值场景,迭代次数可能更少,但这取决于不确定性集合的形状。
我自己的经验是:先用预测场景初始化跑一遍,观察最坏场景出现在哪几个维度,再手动构造一个极值场景作为新的初始场景,可以加快收敛不到10轮。小规模模型可以忽略这个调优,但大规模(比如52周安全约束机组组合问题)省一轮迭代能省不少时间。
5.5 从论文仿真到工程落地的差距
论文里常用的微网模型一般只做1小时或15分钟的调度,阶数少,鲁棒优化跑起来很快。实际工程问题往往是几千个节点、8760小时潮流约束的联合优化,场景集合里的每一条约束对应的是完整的潮流方程,计算量立刻膨胀。
这个阶段需要用场景缩减技术或者并行计算来加速。场景缩减可以用经典的快速前向选择算法,把所有历史场景聚类成有代表性的若干场景;主问题里的model.update()过程也可以设置成懒更新。不过算法细节每套系统都不太一样,这里就不展开讲了。
如果你只是复现论文里的微网模型,我强烈建议先从3个节点、24小时、两台机组这类迷你规模开始,先把代码框架调通,再逐步放大。直接上大规模模型会让代码里同时出现对偶推导问题、求解器配置问题、算法收敛问题,三个问题纠缠在一起,几乎没法调试。
这套程序写完之后,稍微改改数据文件就能迁移到配电网、综合能源系统、虚拟电厂这类场景。两阶段鲁棒优化本身不局限在微网,它的核心思想——"先用保守的日前计划保底,再用灵活的日内调度兜底"——在大多数存在预测不确定性的调度问题里都能用上。