去年在做一个园区微网经济调度项目时,我把原先靠确定性MILP跑的调度模型推翻重写,换成了两阶段鲁棒优化。原因很直接:光伏预测出力和实际出力之间的偏差,在天气突变时能到30%以上,固定机组组合根本无法应对,实时调整成本超过计划成本20%的情况我遇到过不止一次。换用MATLAB+YALMIP+CPLEX实现两阶段鲁棒调度后,日前计划在最坏出力场景下也能保证功率平衡,代价只是常规场景下成本增加3%左右。这篇文章会把建模、C&CG求解、对偶变换、代码骨架和调参经验完整过一遍,给正在做微网经济调度或综合能源系统优化的同学作参考。
说明一下:下面给出的代码是符合两阶段鲁棒优化标准实现的骨架逻辑,核心流程可以直接复现,机组数量、成本系数、不确定集边界这些参数需要按你自己的系统填。
1. 确定性调度在微网场景下的失灵:为什么必须引入两阶段鲁棒
1.1 确定性模型的两个致命假设
确定性经济调度里,光伏出力和风电出力就是一组常数——预测值。整个优化模型隐含了两个假设:第一,预测误差足够小,小到不影响机组组合方案;第二,即使预测有偏差,偏差也不会导致调度方案不可行。这两个假设在大电网里勉强成立,因为大电网有大量机组和充足备用,局部新能源波动可以被系统级备用吸收。但微网不一样,微网的装机容量小、转动惯量低、联络线功率上限严格,单台光伏逆变器被云层遮挡几秒钟,净负荷就可能跳变几十千瓦。对上百千瓦规模的微网来说,这已经是大扰动。
我在项目里做过一次对比:用确定性模型算出日前计划,然后把实际光伏出力序列代入做实时调整仿真,结果功率不平衡最严重的时段,需要燃气轮机在15分钟内爬坡50%额定出力。机组爬坡约束直接越限,只能靠切负荷补缺口。这说明确定性模型求出的"最优解",在真实场景里往往不可行。
1.2 随机优化和鲁棒优化到底怎么选
面对不确定性,学术界和工程界有两条路:随机优化和鲁棒优化。随机优化的前提是已知不确定量的概率分布,然后采样生成大量场景,目标函数变成这些场景下的期望成本。它的问题有两个:一是分布本身往往是估出来的,估不准就白搭;二是尾部风险难以控制,99%的场景都覆盖了,剩下那1%的极端场景一旦发生,损失可能是灾难性的。鲁棒优化不需要概率分布,只需要不确定量的边界,它求解的是"在不确定集内任何实现下,方案都可行且成本可控"的强健解。两阶段鲁棒把这种思想放进"日前决策+实时调整"的结构里,和微网调度业务天然匹配。
1.3 业务流和模型流的对应关系
微网调度的实际业务流程是两层的。日前层做机组启停、储能充放电状态、购售电协议安排,这些决策必须提前确定,临时改动代价极高。实时层根据光伏和风电的实际出力,在已开机机组之间做经济功率分配,调整储能充放电功率和联络线功率。两阶段鲁棒的min-max-min结构正好对应这个流程:外层min做日前决策,内层max-min寻找最坏场景并计算对应的最优实时调整成本。一句话总结,两阶段鲁棒回答的是:"如果光伏和风电同时跑到最不利的组合,我的日前计划还能不能保证功率平衡?实时调整成本会不会失控?"
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 两阶段鲁棒模型的数学构造:不确定集、决策变量与约束体系
2.1 盒式不确定集和预算参数Γ
标准做法是盒式不确定集加预算约束。以风电出力P_w为例:
U =
第一组不等式限定了每个时段出力的上下边界,P_w^dev一般取历史预测误差的95%分位数。第二组是预算约束,限制整个调度周期内偏离预测值的总幅度。Γ从0增大到T,模型的保守程度从"完全相信预测值"单调过渡到"每个时段都做最坏准备"。以24时段为例,Γ取3到5通常就能覆盖绝大多数风险场景,没有必要取满。
这里要提醒一点:不确定集不是越宽越好。不确定集越大,鲁棒性越强,但调度成本也越高。设计不确定集时应该基于历史预测误差数据,而不是拍脑袋取一个百分比。
2.2 第一阶段和第二阶段决策变量如何划分
第一阶段(here-and-now)决策变量包括:机组启停状态u_{i,t}(0-1变量)、储能充放电状态变量、购售电状态变量。这些变量在不确定性实现之前必须确定。第二阶段(wait-and-see)决策变量包括:各机组实际出力P_{i,t}、储能充放电功率P_bat,t、与配网的交换功率P_grid,t、切负荷量。这里有个关键点:第二阶段变量必须是连续变量,保证内层min问题是一个线性规划(LP)。只有LP才能做对偶变换,否则子问题的max-min无法改写成单层优化问题——这是整个求解架构能够成立的前提。
2.3 目标函数和约束条件完整展开
完整的数学模型是:
min_{u} [ C_start(u) + max_{P_w∈U} min_{x,y} ( C_fuel(x) + C_grid(y) + C_bat(y) ) ]
约束条件分三类:
- 功率平衡约束:ΣP_{i,t} + P_w,t + P_bat,t + P_grid,t = P_load,t,注意P_w,t是不确定量,出现在等式右侧。
- 设备运行约束:机组出力上下限与爬坡约束:u_{i,t}·P_i,min ≤ P_{i,t} ≤ u_{i,t}·P_i,max;储能SOC递推约束:SOC_t = SOC_{t-1} + η_c·P_ch,t·Δt - P_dis,t·Δt / η_d;联络线功率上限。
- 第一阶段逻辑约束:机组最小启停时间、购售电互斥等。
不确定量P_w,t出现在功率平衡约束的右侧,这个位置对后面的求解非常关键。对偶变换后,P_w会进到对偶问题的目标函数里,和功率平衡约束对应的对偶变量相乘,形成一个双线性项。这个双线性项是代码实现中最容易卡壳的地方。
3. C&CG求解架构:主问题/子问题分解与对偶变换的完整逻辑
3.1 为什么选C&CG而不是Benders分解
两阶段鲁棒问题的主流解法有Benders分解和C&CG(列与约束生成)两种。两者都要在主问题和子问题之间迭代,但返回给主问题的"东西"不一样。Benders返回的是割平面约束,是对最坏场景成本的一个近似外包络;C&CG则把子问题找到的最坏场景完整地加入主问题,新增一组第二阶段变量和约束。实际测试中,C&CG的迭代次数通常比Benders少一个数量级——大多数情况下10到30次收敛,而Benders经常要跑上百次。对于微网经济调度这种第二阶段LP规模不算大的问题,C&CG的优势非常明显。
3.2 主问题与子问题的定义
主问题(MP):
min_{u, η, x^k, y^k} [ C_start(u) + η ]
s.t. 第一阶段约束;对每一个已发现的场景k:C_fuel(x^k) + C_grid(y^k) + C_bat(y^k) ≤ η,且第二阶段约束成立。
这里的η是一个辅助变量,代表"当前已知所有最坏场景下,第二阶段成本的最大值"。每迭代一轮,主问题里增加一组与新场景对应的约束,η的值会被逐步抬高,直到逼近真实最优值。
子问题(SP):给定第一阶段解u*,求解
Q(u*) = max_{P_w∈U} min_{x,y} [ C_fuel(x) + C_grid(y) + C_bat(y) ]
s.t. 第二阶段约束(含P_w)。
子问题的本质是"在不确定集里搜索一个让实时调整成本最大的出力场景"。
3.3 子问题的对偶变换与双线性项处理
子问题的内层min是一个LP,取对偶之后,max-min结构就变成一个max问题。但代价是目标函数里出现了"对偶变量λ × 不确定变量P_w"的双线性乘积项。这部分我建议用big-M法做线性化,具体思路是引入辅助变量z_t表示λ_t × P_w,t,然后利用两个变量都有界这个事实,写出四组线性不等式:
- z ≤ M·d
- z ≤ λ
- z ≥ λ - M·(1-d)
- z ≥ 0
d是辅助布尔变量,M的取值必须不小于λ和P_w的乘积上界。这里有一个非常关键的经验:M值不能取得过大。M取1e6和取1e3,CPLEX的求解时间可能差5到10倍,因为太大的M会破坏LP松弛质量,导致分支定界效率大幅下降。我通常先用松弛版本的问题估算对偶变量的大致边界,再确定M。
3.4 迭代流程和收敛判据
完整迭代流程如下:
- 初始化:设初始场景为预测值P_w^nom,置UB=+∞,LB=-∞,迭代序号k=0。
- 求解主问题,得到u*、η*,更新下界LB=max(LB, C_start(u*)+η*)。
- 固定u*,求解子问题,得到最坏场景P_w^和Q(u),更新上界UB=min(UB, C_start(u*)+Q(u*))。
- 计算相对间隙(UB-LB)/UB。如果小于阈值ε(取0.01或0.005),停止迭代。
- 否则把P_w^*作为新场景加入主问题,k=k+1,返回第2步。
收敛阈值取0.01时,实际调度成本和最优解的偏差通常在0.1%以内,完全没必要追求0.0001的高精度,求解时间会因此翻好几倍。
4. MATLAB+YALMIP+CPLEX落地:代码骨架与调试细节
4.1 环境配置和版本兼容性
推荐组合是MATLAB R2021b及以上 + YALMIP最新版 + CPLEX 12.10或更高。YALMIP的作用是建模层,把优化问题翻译成求解器能识别的标准形式。配好后在MATLAB里运行yalmiptest,确认CPLEX那一行显示OK。如果CPLEX版本太新而YALMIP版本太旧,接口会不兼容,典型表现是optimize函数直接报错,或者求解器识别不出来。
4.2 主问题的YALMIP代码骨架
主问题的核心是定义变量、追加每个迭代场景的约束。我给出一个简化的代码结构:
matlab复制u = binvar(n_gen, T); % 机组启停状态
p = sdpvar(n_gen, T); % 出力
P_grid = sdpvar(1, T); % 联络线功率
eta = sdpvar(1, 1); % 第二阶段成本上界
Constraints = [];
% 第一阶段约束:机组启停逻辑等
Constraints = [Constraints, startup_logic(u)];
% 对每个已发现场景追加第二阶段约束
for k = 1:num_scenarios
p_k = sdpvar(n_gen, T); % 该场景独立的出力变量
Constraints = [Constraints, ...
power_balance(p_k, P_w_scen{k}, P_grid, load), ...
gen_limits(p_k, u), ...
cost_eta(p_k, P_grid, eta, cost_coeff)];
end
objective = startup_cost(u) + eta;
ops = sdpsettings('solver', 'cplex', ...
'verbose', 0, ...
'cplex.mip.tolerances.mipgap', 0.005);
optimize(Constraints, objective, ops);
要注意:不同场景之间绝对不能共用第二阶段变量。如果为了省事,让所有场景共用一组出力变量,主问题求出的场景调度方案会互相干扰,最终结果完全失真。这是新手最容易犯的错误。
4.3 子问题代码骨架与线性化实现
子问题中,不确定变量P_w用sdpvar声明,约束写进不确定集。对偶变量也是sdpvar。双线性项线性化的示意图如下:
matlab复制lambda = sdpvar(size(A,1), T); % 对偶变量
P_w = sdpvar(1, T); % 不确定出力
z = sdpvar(1, T); % 辅助变量 z = lambda .* P_w
d = binvar(1, T); % big-M 辅助布尔变量
M1 = lambda_bound; % 对偶变量上界,需要先估算
M2 = P_w_dev; % 出力偏差上界
Constraints = [dual_constraints, uncertainty_set];
for t = 1:T
Constraints = [Constraints, z(t) <= M1 * d(t)];
Constraints = [Constraints, z(t) <= M2 * lambda(t)];
Constraints = [Constraints, z(t) >= M1 * (d(t)-1) + M2 * lambda(t)];
Constraints = [Constraints, z(t) >= 0];
end
objective = -sum(z) + ...; % 双线性项替代后的目标
ops = sdpsettings('solver', 'cplex', 'verbose', 0);
optimize(Constraints, -objective, ops); % 注意方向
严格来说,双线性项的线性化需要根据λ_t和P_w,t的正负性分别写四组约束,上面只是代表一个方向。M值必须取λ_t和P_w,t的实际边界,太大MILP会数值退化。
4.4 迭代循环与场景追加
外层用while循环,记录LB和UB。每次迭代后把新场景加入场景列表,重新构建主问题约束。如果子问题求解时间过长,可以做一个优化:固定第一阶段决策后,检查第二阶段约束是否存在时段间耦合。如果没有储能SOC这种跨时段约束,子问题可以拆成多个单时段问题并行求解。我在项目里用parfor加速,子问题整体求解时间从80秒降到了12秒。
4.5 一个典型的调试现象
迭代第1次和第2次得到的场景差异很大,第3次开始上下界gap明显减小,但最终结果看起来不对劲。这时候优先检查子问题是否真的求到了"最大"场景:把每轮子问题的目标值和对应的P_w值打印出来,观察它是不是在不确定集边界上移动。另一个常见坑是功率平衡约束的符号写反,导致最坏场景总往反方向跑,找出来的所谓"最坏场景"其实是"最好场景"。
5. 鲁棒调度参数敏感性分析与实战避坑
5.1 Γ参数对调度成本的直接影响
我做过一组测试,把Γ从0一直到T,得到一条成本-鲁棒性曲线,发现曲线有两个明显拐点。拐点之前成本增长平缓,说明系统本身有冗余,储能容量可以吸收不确定性带来的波动;拐点之后成本陡增,意味着要额外启动昂贵的机组才能满足最坏场景。实际配置时应该取拐点附近的Γ值,而不是盲目求"绝对鲁棒"。操作上很简单:跑五六个Γ值,画出成本曲线,选曲率最大的位置作为运行配置。
5.2 迭代震荡和次数过多的处理
最常见的现象是子问题返回的最坏场景在几个候选场景之间来回跳,上下界gap一直降不下去。原因通常是big-M的M值不够紧,或者对偶变量边界估计不准。另一个容易被忽略的原因是数值尺度问题:如果燃气机组成本系数比储能成本系数大两个数量级以上,求解器的数值判断会出问题,建议先把所有成本系数归一化到同一数量级。
5.3 子问题不可行怎么办
固定第一阶段决策后,第二阶段约束在某个极端场景下无解,CPLEX会直接报infeasible。我的处理方式是在功率平衡约束中加松弛变量,分别代表切负荷量和弃光量,并在目标函数中加高额罚项。这不只是数值上的妥协——真实调度业务里本来就允许极端场景下的切负荷动作,只是要让求解器"尽量不用"。
matlab复制Constraints = [Constraints, ...
sum(P_gen) + P_w + P_bat + P_grid + shed == load];
% 目标函数中加 penalty * sum(shed)
罚项系数一般取单位发电成本的5到10倍。太大会造成数值病态,太小会滥用切负荷,反而掩盖了真实的最坏场景。
5.4 结果验证不能只信求解器
鲁棒优化结果必须验证。我用两种方法:第一,从历史数据中抽取实际光伏出力序列,把鲁棒方案代入做仿真,检查所有约束是否满足;第二,用蒙特卡洛抽样生成1000组出力场景,统计真实运行成本的分布。实测下来,鲁棒方案在最坏的2%场景下的成本明显低于确定性方案,在常规场景下成本只高出2%到4%。这个对比数据才是支撑项目决策最有力的东西,而不是求解器给出的目标函数值。
调试两阶段鲁棒代码时,我习惯把每一轮子问题找出的最坏场景P_w值导出到工作区,和预测值画在同一张图上。这个操作能直观地看出系统最怕的是"持续低出力"还是"短时暴跌",比单纯看收敛曲线有用得多。如果发现最坏场景总集中在某个时段,说明系统在那个时段的备用容量不足,优先调整那里的约束配置,往往比调Γ参数更有效。
