做微网电源容量配置有一段时间的人,应该都遇到过这个尴尬:用确定性优化算出来的配置结果,拿到实际运行场景里一验,要么因为风光出力波动太大导致经常切负荷,要么因为把储能配得太满导致投资成本居高不下。于是大家开始把目光转向鲁棒优化,但很快又发现,经典的单阶段鲁棒虽然能扛住最恶劣场景,结果却经常保守到没法用——为了一个概率极低的极端场景,把整个微网的容量水平拉高一大截,算出来的方案在经济性上完全没有竞争力。
这时候两阶段鲁棒优化就成了一个很自然的落点。它的思路说白了就是:第一阶段先做投资决策,把电源容量定下来;等不确定的风光出力真正观测到之后,第二阶段再做运行决策,在给定容量下尽可能经济地调度微网里的各类设备。两个阶段通过“最恶劣场景”衔接起来,也就是在第二阶段里专门去考哪一种风光出力组合对你这个容量方案最不利,然后用这个“最不利场景”下的运行成本来评估方案的投资价值。这套框架既能保留鲁棒优化对不确定性的抗风险能力,又比单阶段鲁棒灵活得多,也正是这两年微网容量配置论文里最常见的方法论之一。
这篇文章我想把整个流程拆开讲清楚,从为什么要用两阶段、模型怎么建,到CCG算法(列与约束生成)怎么在MATLAB里落地,再到代码框架里哪些地方容易埋坑,全部过一遍。内容主要针对已经在做微网优化、但想从确定性模型转向两阶段鲁棒模型的同学,或者正在复现相关论文但被对偶推导、CCG迭代卡住的人。
1. 为什么微网容量配置需要“两阶段+鲁棒”而不是其他方案
1.1 确定性优化解决不了的问题
微网电源容量配置,本质上是一个“投资决策+运行模拟”的嵌套问题。你决定装多少光伏、多少风机、多少储能,这是一个长期决策,发生在项目规划阶段;而设备装完之后,每天怎么调度、怎么充放电、怎么跟大电网交互,这是短期运行决策。传统的容量配置做法,是用典型日或者典型场景代替一整年的运行情况,把运行模拟简化成一堆约束,塞进同一个优化模型里一起求解。这个方法的好处是模型简单、能直接求出容量结果,坏处是你选的“典型场景”得足够准,如果风光出力的波动特性没有被覆盖到,算出来的容量就可能在某些实际会出现的情况下不够用。
我最早做微网配置时用的就是典型日法,当时取了春夏秋冬四个典型日,结果算出来的方案在验证时连续出现了好几天净负荷预测偏差超过百分之二十的情况,储能系统频繁被充到上限,最后只能靠买电兜底。后来我去统计了一下那几天的实际天气数据,发现典型日选取时平滑掉了连续阴天和连续大风天的极端情况,而这些恰恰是对容量配置结果影响最大的场景。
1.2 单阶段鲁棒为何“保守到没法看”
有人可能会说,既然典型场景覆盖不了极端情况,那我直接用最恶劣场景来做不就行了?把过去二十年里风光出力最差的那一天翻出来,按那个场景去配置容量,总不会出问题了吧。这其实就是单阶段鲁棒优化的思路——整个微网系统在同一个最恶劣场景下完成投资和运行决策。
但这个思路有一个很大的问题:它把“投资决策”和“运行决策”捆绑在了同一个场景下。也就是说,它默认光伏出力最低的时候,储能系统也只能按这个场景来配置;可实际运行中,储能系统的充放电策略是可以随着风光出力变化实时调整的。容量配置定下来之后,运行策略本来就是灵活应对各种场景的。单阶段鲁棒完全没利用这种“事后调整”的灵活性,所以它算出的方案一定会过度保守。
我记得有人做过对比,同一个微网系统,用单阶段鲁棒算出来的总容量配置成本,比用两阶段鲁棒高出了将近百分之二十。这个差距在工程上非常致命——一个省了百分之二十成本的方案,如果在风险水平接近的情况下能被验证可行,那业主没有任何理由去选更贵的那个。
1.3 两阶段鲁棒的真正价值:先决策,后调整
两阶段鲁棒的建模思路,恰好就对准了容量配置问题的这个结构性特点。
它把决策变量分成两类:
- 第一阶段变量:微网里各类电源的投建容量。这些变量在不确定量实现之前就要确定下来,属于“现在就要拍板”的决策。
- 第二阶段变量:在给定容量下,各个时段内微网的运行状态,比如储能充放电功率、燃气轮机出力、与电网交互功率、切负荷量等。这些变量取决于风光出力实际是多少,属于“看到结果之后再调整”的决策。
而它要解决的数学问题是:在所有可能的风光出力场景中,找出让你的“投资成本+运行成本”最大的那个场景,然后把这个最坏情况下的总成本降到最低。
翻译成人话就是:你要在不知道未来天气到底如何的情况下先决定装多少设备;装完之后,无论天气怎么变化,你的微网都可以通过合理的运行调度来应对;但你要确保的是,即使在对你最不利的天气下,系统也能跑得下去,而且总成本在可接受范围内。
这个逻辑本质上和很多实际工程决策是一模一样的。你在决定要不要建一条生产线的时候,不会假设所有订单都能按计划来;你会考虑“如果订单量比预期低百分之三十,这条线能不能扛得住”,但扛的方式不是把生产线本身设计成低产能,而是通过排产、库存等方式去应对波动的订单量。两阶段鲁棒干的正是这件事:投资层面提供“基本盘”,运行层面消化“波动性”。
1.4 适用边界:不是所有微网都适合用这个算法
说了这么多两阶段鲁棒的优势,也要提醒一句,不是所有微网容量配置项目都应该上这个算法。
两阶段鲁棒的模型规模和求解复杂度,比确定性优化高了一个量级。CCG算法虽然能有效处理这类问题,但每一轮迭代都要重新求解主问题和子问题,碰到设备类型多、时间尺度细、约束条件复杂的场景,总求解时间有可能是确定性优化的几十倍甚至上百倍。如果你的项目本身对精度要求不那么高,或者可用的风光历史数据不足以支撑一个可靠的不确定集,那用机会约束或者简单的鲁棒优化可能反而更实际。
就我个人的经验来说,两阶段鲁棒最适合的应用场景是这类:微网中可再生能源渗透率较高,且孤岛运行或严格并网约束下不允许切负荷(或者切负荷代价极高),同时又要求方案能覆盖多年运行数据中的极端场景。如果条件不满足,算法复杂度带来的收益可能并不明显。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 容量配置两阶段鲁棒模型的完整数学形式
2.1 问题描述与基本假设
在建模之前,先把问题边界框清楚。
假设有一个并网型微网,包含以下候选电源设备:风力发电机组(WT)、光伏阵列(PV)、储能系统(ESS),以及作为可调度电源的燃气轮机(MT)。微网与上级电网之间存在购电/售电交互,但交互功率存在上限。规划目标是确定各候选电源的安装容量,使得在满足负荷需求的前提下,考虑最恶劣风光出力场景下的系统总成本最小。
做这个模型之前有几个基本假设要明确下来:
- 不确定量只有风光出力,负荷需求按确定性预测值处理(也可扩展为负荷不确定,但模型复杂度会显著上升);
- 典型日采用一小时为时间间隔,规划周期取一年,选取若干典型日来代表全年运行状态;
- 储能系统的功率容量和能量容量分开决策,充放电效率、自放电率等参数按厂商数据给定;
- 为了控制模型规模,燃汽轮机的运行建模采用线性化方式,即只考虑出力上下限和爬坡约束,不显式建模启停状态(如果要考虑启停,需要引入二进制变量,CCG子问题的处理方式会产生比较大的变化)。
这些假设是我在多次实验中逐步确定下来的。如果你一开始就把模型建得非常细,比如考虑储能老化成本曲线、燃气轮机启停费用、光伏降额曲线等,主问题和子问题的规模都会迅速膨胀,CCG的收敛速度可能慢到让你怀疑人生。我建议第一版先把主干模型跑通,再逐步加细节。
2.2 第一阶段:投资决策问题
第一阶段的决策变量是各设备的安装容量:
- (x_{WT}):风机安装容量(kW)
- (x_{PV}):光伏安装容量(kW)
- (x_{ESS,p}):储能额定功率(kW)
- (x_{ESS,e}):储能额定能量(kWh)
- (x_{MT}):燃气轮机安装容量(kW)
第一阶段的目标函数是投资成本的等年值:
[
\min_{x} \quad C_{inv} = \sum_{i \in \Omega} c_i^{inv} \cdot x_i
]
其中 (c_i^{inv}) 是各设备的单位容量投资成本(考虑等年值折算系数)。注意,我这里用的是“等年值”概念,也就是把一次性投资成本通过资金回收系数折算到每一年,这样能和年运行成本直接相加。
第一阶段还需要满足基本的资源约束,比如各类电源的安装容量不能超过场地或政策限制的最大值:
[
0 \le x_i \le x_i^{max}, \quad \forall i \in {WT, PV, ESS, MT}
]
到这里,数学上第一阶段的表述是比较干净的。但实际上,真正的耦合关系全部体现在第二阶段的运行约束里——你装了多大容量的光伏,决定了第二阶段每个时段光伏出力的上限;储能容量决定了每个时段储能的充放电功率边界和能量边界。这种“第一阶段定边界、第二阶段跑运行”的结构,正是两阶段规划问题的标准范式。
2.3 第二阶段:运行调度与最恶劣场景刻画
第二阶段的问题是在给定第一阶段决策变量 (x) 和不确定量实现值 (u) 的条件下,求解微网的最优运行策略。数学上写作:
[
f(x, u) = \min_{y} \quad C_{op}(y)
]
其中第二部分变量 (y) 包括:
- (P_{MT}(t)):燃气轮机各时段出力
- (P_{ESS,c}(t), P_{ESS,d}(t)):储能各时段充放电功率
- (P_{grid,b}(t), P_{grid,s}(t)):向电网购电和售电功率
- (P_{curt}(t)):切负荷功率(如果允许切负荷)
运行成本包括燃气轮机的燃料成本、向电网购电的费用(减去售电收入),以及如果发生切负荷需要支付的惩罚成本。
第二阶段的核心约束包括:
功率平衡约束(每个时段):
[
P_{WT}(t) + P_{PV}(t) + P_{MT}(t) + P_{ESS,d}(t) + P_{grid,b}(t) = P_{load}(t) + P_{ESS,c}(t) + P_{grid,s}(t) + P_{curt}(t)
]
这个约束是问题的核心,它把所有设备的出力、储能状态、电网交互和负荷需求在每一个时段内连接起来。
风电/光伏实际出力约束:
[
0 \le P_{WT}(t) \le \eta_{WT} \cdot x_{WT} \cdot u_{WT}(t)
]
[
0 \le P_{PV}(t) \le \eta_{PV} \cdot x_{PV} \cdot u_{PV}(t)
]
其中 (\eta_{WT}) 和 (\eta_{PV}) 是考虑各种损耗后的出力折算系数,(u_{WT}(t)) 和 (u_{PV}(t)) 是归一化的风光出力系数(标幺值),也就是不确定量。
储能系统约束:
[
0 \le P_{ESS,c}(t) \le x_{ESS,p}
]
[
0 \le P_{ESS,d}(t) \le x_{ESS,p}
]
[
SOC(t) = SOC(t-1) + \eta_c \cdot P_{ESS,c}(t) - \frac{P_{ESS,d}(t)}{\eta_d}
]
[
0 \le SOC(t) \le x_{ESS,e}
]
燃气轮机出力约束:
[
0 \le P_{MT}(t) \le x_{MT}
]
电网交互约束:
[
0 \le P_{grid,b}(t) \le P_{grid}^{max}
]
[
0 \le P_{grid,s}(t) \le P_{grid}^{max}
]
切负荷量约束:
[
0 \le P_{curt}(t) \le P_{load}(t)
]
可以看到,第二阶段的运行约束中处处都有第一阶段变量 (x) 的影子。这就是两阶段规划中“第一阶段确定资源上限、第二阶段在线调度”的结构特性。
2.4 整个问题的三层结构(min-max-min)
把两个阶段串起来,我们最终要解的优化问题是下面这种三层结构:
[
\min_{x \in X} \quad C_{inv}(x) + \max_{u \in U} \min_{y \in Y(x, u)} C_{op}(y)
]
这个形式在论文里经常出现,但对很多刚接触的人来说,第一眼看到会觉得比较复杂。我用最直白的方式拆一下这个三层结构分别对应什么问题:
- 外层 (min_x):在规划阶段决定装多少设备,这是投资决策层;
- 中层 (max_u):在给定容量配置之后,自然推出最恶劣的风光场景,这是对抗层(可以理解为大自然在跟你博弈);
- 内层 (min_y):在给定的容量和最恶劣场景下,最优的运行调度策略是什么,这是运行层。
前两层是投资决策与大自然之间的博弈,后两层本质上是一个“在最恶劣场景下做最优运行”的子问题。CCG算法的核心思路就是把这个三层问题拆成一个主问题和子问题,然后用迭代的方式逼近最优解。
有了这个清晰的问题结构,接下来就是关键的求解算法部分。
3. 风光不确定集建模:盒子、预算、椭球选型
3.1 为什么不确定集的形状直接影响结果
两阶段鲁棒优化里面有一个很容易被忽略的决定性因素——不确定集的构造方式。同样一套容量配置模型,用盒式不确定集和用预算不确定集,算出来的最优容量可能相差百分之十甚至更多。这个现象本质上是因为不确定集决定了“最恶劣场景”能恶劣到什么程度;不确定集的范围越大,最恶劣场景可能越极端,为了应对这个极端场景,容量配置就越保守。
在做两阶段鲁棒容量配置时,不确定集的选择不只是数学上的偏好问题,它直接反映了决策者对风险的态度。你愿意承担多大的风光出力波动,决定了你的不确定集要“胖”到什么程度。
3.2 常用的三类不确定集
目前微网容量配置文献中用得最多的不确定集有三类,我分别说一下它们的特性和适用场景。
第一类是盒式不确定集。数学形式上最简单:
[
U = { u: u^{min} \le u \le u^{max} }
]
也就是说每个时段的风光出力系数都在一定范围内独立波动。这种不确定集的优点是形式简单、鲁棒性最强,因为它允许所有时段的风光出力同时取到最不利值。缺点是过度保守——实际中连续多天阴天是有可能的,但所有时段同时达到历史最差值且天天下雨的情形几乎不会出现。
第二类是预算不确定集,也叫Budget Uncertainty Set。它在盒式集的基础上增加了一个预算约束:
[
U = { u: u^{min} \le u \le u^{max},\ \sum_t \frac{|u_t - u_t^{nom}|}{\Delta u_t} \le \Gamma }
]
其中 (u_t^{nom}) 是预测值,(\Delta u_t) 是最大偏移量,(\Gamma) 是预算参数,用来限制总偏移量。这个集的直观理解就是:我可以允许风光出力偏离预测值,但不会让太多时段同时发生大幅偏离。(\Gamma) 越小,不确定集越小,模型越乐观;(\Gamma=0) 时退化为确定性模型。
(\Gamma) 的这个取值没有统一的答案,需要你结合风光出力的历史统计特性来确定。如果历史数据显示百分之九十五的情况下,单日总偏移量不超过预测值的某个百分比,那 (\Gamma) 就应该按这个统计特性来设置。
第三类是椭球不确定集。它的数学形式基于协方差矩阵,可以描述各时段风光出力之间的相关性:
[
U = { u: (u - u^{nom})^T \Sigma^{-1} (u - u^{nom}) \le \Omega^2 }
]
这个集的优势在于能刻画不确定量之间的相关结构——比如相邻时段的风速往往不是独立的,而是有强正相关的。缺点是引入二次约束后,模型的求解复杂度显著上升,且在一些问题中会导致第二阶段问题不再是标准线性规划,给对偶求解带来额外困难。
3.3 我的选择:离散化+预算约束组合
在我自己的项目中,我一般倾向于用预算不确定集,做离散化处理后配合CCG算法。为什么?
- 预算不确定集保持线性,子问题对偶后仍能保持结构清晰,利于CCG迭代;
- (\Gamma) 参数可以直观地调节保守程度,在做方案对比时非常方便(比如可以算出一组不同 (\Gamma) 取值下的容量配置方案,形成Pareto前沿给决策者选);
- 椭球集虽然更精确,但在CCG框架里处理起来需要额外的二阶锥规划工具,调试成本较高,对于工程应用可能不划算。
如果读者想更精确地建模不确定量之间的时间相关性,可以考虑“带时间耦合的预算约束”方案。我和团队之前试过一种做法,在预算约束里加入相邻时段偏移量的互相限制,效果类似简化版的椭球集,但保持了线性结构。代码实现时本质上就是在约束矩阵里多写几行线性不等式,不会带来数值求解上的困难:
[
|u_t - u_t^{nom}| \le (1+\alpha) \cdot |u_{t-1} - u_{t-1}^{nom}|, \quad \forall t \ge 2
]
这个约束表达的意思是:如果上一个时段的风光偏移已经很大(说明天气系统出了问题),那当前时段也不太可能瞬间恢复正常,而是在此基础上继续维持类似的偏移程度。(\alpha) 的取值决定了偏移的衰减速度。这一招可以大幅降低传统盒式集中“所有时段同时取极端值”的保守性,而代价只是多几条线性约束。
3.4 一个小提醒:归一化别搞错方向
不确定集建模时最容易出问题的地方,反而不是各种高级技巧,而是归一化系数的定义方向。
在风电出力模型中,假设装机容量是1000kW的机组,某一时段的可用出力比例 (u_t) 是0到1之间的数,那这个时段的风电最大出力就是 (1000 \times u_t)。如果这里的 (u_t) 用的是“预测出力/装机容量”的比例,那它的上下界应该是从历史数据中统计出来的归一化出力上下界。
但有些论文为了简化描述,直接用“实际出力/额定出力”的归一化方式,这时 (u_t) 的分布形态和历史风速分布直接相关,上下界的统计口径也不同。我见过不止一次代码里把这两种口径混在一起,导致算出来的风光出力上限要么过大要么过小,结果整个容量配置方案偏差严重。
比较好的做法是在建模初就统一出一个表格,把所有归一化变量的定义、取值范围、上下界是怎么来的写清楚。比如我用的是“预测出力系数”(\xi_t \in [0.1, 1.0]),那(\xi_t)的统计口径必须全部来自同一套历史数据同一种折算方法。建模中途换口径,调试的时候根本找不出问题在哪。
4. CCG(列与约束生成)算法求解框架解析
4.1 主问题与子问题的分工逻辑
两阶段鲁棒优化有多种求解方法,包括Benders分解、C&CG(Column and Constraint Generation,列与约束生成)以及近年来比较流行的自适应鲁棒优化求解策略。在微网容量配置这个具体问题中,最常用、也相对容易实现的是CCG算法。
CCG的核心思路可以用一句话概括:把原问题的第二阶段变量和对应约束“猜”出来一部分,在不知道所有场景的情况下,用一个迭代过程让主问题和子问题交替逼近最优解。
具体来说,CCG把原问题拆成主问题(Master Problem)和子问题(Subproblem):
主问题是一个“确定性”的容量配置加若干个特定场景下的运行问题。它的形式是:
[
\min_{x, y, \theta} \quad C_{inv}(x) + \theta
]
其中 (\theta) 是一个辅助变量,表示第二阶段最恶劣场景运行成本的“当前估计值”。在主问题里,对每一个已找到的场景 (\hat{u}^{(k)}),我们都要加上对应的运行约束(即第二阶段约束中把不确定量固定在该场景下):
[
\theta \ge C_{op}(y^{(k)})
]
[
y^{(k)} \in Y(x, \hat{u}^{(k)})
]
子问题则是给定一组第一阶段决策变量 (\hat{x}),找最恶劣场景下系统的最优运行方案:
[
SP(\hat{x}): \quad \max_{u \in U} \min_{y \in Y(\hat{x}, u)} C_{op}(y)
]
从算法结构上看,主问题的角色是“提出候选方案”,子问题的角色是“检验方案”。主问题给出一个容量配置方案和对应的预估成本,子问题把这个方案拿到最恶劣场景中去“极限测试”,如果发现有场景会导致运行成本超过当前估计,就把这个场景作为新的约束加入主问题,让主问题下轮迭代时重新优化容量配置。
这个过程不断循环,直到主问题给出的总成本下界与子问题算出的真实最恶劣场景成本上界之间的差距足够小。此时算法收敛,输出当前的容量配置方案。
4.2 为什么子问题的内层min可以对偶上去
在实现CCG时,最核心的技术细节是求解子问题:
[
\max_{u \in U} \min_{y \in Y(\hat{x}, u)} C_{op}(y)
]
这是一个“max-min”形式的双层优化。直接求解很困难,因为外层在试图找最恶劣场景,内层在试图找该场景下的最优运行方案,两个玩家在同一组约束下博弈。
但这里有一个关键的数学性质:如果第二阶段运行问题是一个线性规划(或者可以等价改写成线性规划),那么内层的min问题满足强对偶条件。这时我们可以利用线性规划对偶理论,把内层min问题改写成它的对偶问题(一个max问题),然后与外层的max合并成一个max问题:
[
\max_{u \in U} \min_{y \in Y(\hat{x}, u)} C_{op}(y) = \max_{u \in U, \lambda \in \Lambda(u)} \lambda^T b(\hat{x}, u)
]
合并之后,原来的“max-min”结构变成了一个单层的max问题,可以用标准的优化求解器直接处理。
这个转换是CCG算法能够高效落地的理论基石。许多论文中提到的“KKT条件法”实际上也与此相关——它把内层问题用其KKT条件代替,得到等价的单层问题。KKT条件中包含了原问题的约束、对偶问题的约束以及互补松弛条件,是一个数学上更严格的表述方式。
从我个人的实现经验来看,如果你对推导过程不是特别熟练,直接用强对偶来合并max-min比用KKT条件更容易写对、更容易调试。KKT条件需要显式处理互补松弛约束(通常要用大M法或者SOS1约束来线性化),稍不留神就会引入数值问题;而强对偶法只需要把对偶问题的约束写出来,然后把目标函数里的内层对偶目标和外层max合并即可。两种方法在数学上是等价的,但强对偶法在工程实现上更不容易出错。
4.3 子问题对偶后出现的“双线性项”怎么处理
在对偶化的过程中会遇到一个麻烦:对偶后的子问题中,会出现对偶变量和不确定量 (u) 相乘的项,即双线性项。比如某条对偶约束可能是 (\lambda_1 + \lambda_2 \cdot u_t \le c),而在目标函数中也存在 (\lambda \cdot u_t)。
这种双线性项让子问题不再是线性规划,而是一个非凸的二次优化问题(准确地说,是带有双线性目标或约束的优化问题)。
在不考虑特殊结构的情况下,这类问题的全局最优解很难求。但两阶段鲁棒优化在微网容量配置中有一个非常好的结构特点——不确定量 (u) 是独立于第一阶段变量 (\hat{x}) 的“外部参数”,它只出现在约束的右侧常数项(RHS)中。这意味着,如果固定了对偶变量 (\lambda) 的取值,那么子问题关于 (u) 的目标函数是线性的(或至少是线性的),可以直接在不确定集上优化。
换言之,这个双线性问题具有“固定一个变量后关于另一个变量线性”的性质。以我的经验,对于这个具体结构,一个很有效的处理方式是不确定量离散化配合枚举,或者使用交替方向法循环求解。
具体来说,常用的替代方法之一是把不确定集 (U) 中的每个分量离散成若干档位,然后对(\lambda)和(u)做交替优化,或者直接用大M法把双线性项线性化,再调用求解器求解。
不过这里要特别提醒一下:对大M法线性化双线性项,M值的选取极其关键。M取小了,可能会错误地排除可行解——也就是说,真正的最恶劣场景被大M约束削掉了;M取大了,会导致数值病态,求解器可能出现精度问题,收敛判据会变得不稳定。我建议对双线性项的可行域做一个预分析,用每个变量取值范围的乘积上界来确定一个尽量小的有效M值。
4.4 CCG迭代终止条件与收敛性把握
CCG算法的迭代过程有两个关键量:
- 主问题目标值 (LB):因为主问题是原问题的松弛(只考虑了部分场景),所以它给原问题提供了一个下界;
- 子问题目标值 (UB):因为给定主问题的解 (x^{(k)}) 后,子问题算的是该方案在最恶劣场景下的真实总成本,所以它是原问题的上界。
标准的迭代终止条件是:
[
UB - LB \le \varepsilon \cdot \max{1, |LB|}
]
其中 (\varepsilon) 是预设的最优性间隙,一般取0.01或0.02。
在实际迭代中,我注意到一种常见的情况:前几轮迭代时上下界差距下降得很快,但到了后期会出现“锯齿状”波动,也就是UB下降一点、LB上升一点,整体差值收敛得很慢。这时候先别急着调代码,先检查一下子问题求出的“最恶劣场景”重复率是不是很高。如果连续好几轮找出的场景都是同一个(只是数值上有微小差异),说明CCG算法的场景库已经趋于饱和,真正的最恶劣场景大概率已经被发现了,但主问题中的 (\theta) 对应的运行约束可能需要进一步精化——可以考虑增加新的割平面,或者细化不确定集的离散化粒度。
还有一种常见问题是迭代发散,UB不降反升。这种情形下最常见的成因是子问题在求“最恶劣场景”时求解器并没有真的收敛到全局最优,而是陷入了局部最优,导致返回的场景不是真正最恶劣的,子问题目标值偏小,给了主问题一个“虚假的安全感”。针对这个问题,要看子问题求解器的输出日志,确认最优性间隙确实为0。如果子问题本身规模很大,求解器在默认精度下停止迭代,需要手动收紧求解器的容忍度参数。
在我的代码实现里,CCG的迭代循环通常设置两层退出机制:第一层是上下界间隙小于阈值时正常退出;第二层是迭代次数超过预设上限时报错退出。第二层虽然有点简单粗暴,但可以防止程序陷入死循环白跑一晚上。
5. MATLAB代码骨架与实现细节拆解
5.1 代码整体结构与模块划分
先明确一点:下面的代码展示的是完整可运行的框架,但省略了所有具体参数的赋值细节。实际应用时你需要根据自己的微网系统替换负荷曲线、风光历史数据、设备参数和设备成本参数。
我把整个程序按功能拆成六个模块,放在一个项目文件夹下:
main.m:主程序入口,初始化系统参数,调用CCG迭代循环;build_uncertainty_set.m:构建风光出力的预算不确定集,输出不确定量的上下界矩阵和预算参数;build_master_problem.m:构建CCG主问题,输入当前已知的场景集合,返回一个优化模型(我用YALMIP建模);solve_subproblem.m:求解子问题,给定第一阶段的容量配置方案,返回最恶劣场景和对应的运行成本;add_scenario_to_master.m:将子问题发现的新场景以约束的形式加入主问题;plot_results.m:结果可视化,绘制最优容量配置图和典型日运行曲线。
这个模块划分是经过几次迭代后确定的,核心思路是让“主问题和子问题”各自独立建模,避免在迭代循环里反复修改同一份模型变量。
5.2 系统参数定义与初步数据准备
在 main.m 中最开始的部分,需要定义整个微网系统的参数。我这里贴出一份核心代码:
matlab复制%% 系统基础参数
% 时段数(以小时为单位)
T = 24;
% 典型日数
D = 4;
% 负荷数据,维度 D x T,单位 kW
P_load = load('load_data.mat').P_load;
% 光伏归一化出力系数上限/下限,维度 D x T
xi_pv_max = load('pv_data.mat').xi_pv_max;
xi_pv_min = load('pv_data.mat').xi_pv_min;
% 风电归一化出力系数上限/下限,维度 D x T
xi_wt_max = load('wt_data.mat').xi_wt_max;
xi_wt_min = load('wt_data.mat').xi_wt_min;
%% 候选设备参数
% 单位投资成本 [元/kW] 或 [元/kWh]
c_inv = struct('WT', 8000, 'PV', 6500, 'ESS_p', 1800, 'ESS_e', 2500, 'MT', 5000);
% 设备容量上限 [kW] 或 [kWh]
x_max = struct('WT', 2000, 'PV', 2000, 'ESS_p', 1000, 'ESS_e', 2000, 'MT', 800);
%% 运行参数
price_buy = 0.8; % 购电电价 [元/kWh]
price_sell = 0.4; % 售电电价 [元/kWh]
c_gas = 2.2; % 天然气价格 [元/m3]
eta_MT = 0.35; % 燃气轮机发电效率
LHV_gas = 9.7; % 天然气低位热值 [kWh/m3]
eta_ch = 0.95; % 储能充电效率
eta_dis = 0.95; % 储能放电效率
P_grid_max = 1500; % 与大电网交互功率上限 [kW]
%% CCG算法参数
epsilon = 0.01; % 最优性间隙
max_iter = 30; % 最大迭代次数
Gamma = 12; % 预算参数,需要根据实际数据标定
这里的负荷数据我习惯用多维矩阵 P_load(d, t) 来存储,第一维是典型日编号,第二维是时段编号。多个典型日并列的好处可以在后续子问题求解时直接做向量化运算,大幅提升效率。
5.3 不确定集构建的实现
构建不确定集这个模块相对简单,但对于后续求解的影响非常大。以预算不确定集为例:
matlab复制function U = build_uncertainty_set(Gamma, xi_min, xi_max, xi_ref, D, T)
% 构建预算不确定集
% 输入:
% Gamma - 预算参数
% xi_min, xi_max - 各时段风光出力系数上下界
% xi_ref - 预测出力系数
% 输出:
% U - 不确定集结构体
U.type = 'budget';
U.xi_min = xi_min;
U.xi_max = xi_max;
U.xi_ref = xi_ref;
U.Gamma = Gamma;
% 各时段的偏移幅度
U.delta_xi = xi_max - xi_ref;
% 不确定量总个数 = 典型日数 * 时段数 * 2(风光两种资源)
U.N_scenarios = D;
U.T = T;
end
在具体做容量配置时,由于风光资源在典型日之间往往没有明显的相关性,可以按典型日独立设置预算约束。也就是每个典型日内部的 (\Gamma) 限制是独立的,这个处理方式也符合工程直觉——不同典型日之间本来就代表不同的天气类型,不应该用同一个总预算去限制。
5.4 主问题建模:YALMIP表达式与CCG场景库机制
我一般用YALMIP做建模,因为它对“在迭代中逐步添加约束”这种场景支持得比较好。主问题的核心代码如下:
matlab复制function [model, sol_x] = build_master_problem(scenario_set, params)
% 构建CCG主问题
% scenario_set - 当前已知的场景集合,每个场景是一个结构体,包含
% xi_pv(D,T), xi_wt(D,T)
% 返回YALMIP模型和最优解
% YALMIP变量定义
x_WT = sdpvar(1, 1);
x_PV = sdpvar(1, 1);
x_ESS_p = sdpvar(1, 1);
x_ESS_e = sdpvar(1, 1);
x_MT = sdpvar(1, 1);
% 主问题辅助变量,theta用于逼近最恶劣场景运行成本
theta = sdpvar(1, 1);
% 场景数
K = length(scenario_set);
% 针对每个场景定义运行变量
P_MT = cell(1, K);
P_ESS_c = cell(1, K);
P_ESS_d = cell(1, K);
P_gb = cell(1, K);
P_gs = cell(1, K);
P_curt = cell(1, K);
SOC = cell(1, K);
for k = 1:K
P_MT{k} = sdpvar(params.D, params.T, 'full');
P_ESS_c{k} = sdpvar(params.D, params.T, 'full');
P_ESS_d{k} = sdpvar(params.D, params.T, 'full');
P_gb{k} = sdpvar(params.D, params.T, 'full');
P_gs{k} = sdpvar(params.D, params.T, 'full');
P_curt{k} = sdpvar(params.D, params.T, 'full');
SOC{k} = sdpvar(params.D, params.T, 'full');
end
% 目标函数:投资成本 + 最恶劣场景下的运行成本
C_inv = params.c_inv.WT * x_WT + params.c_inv.PV * x_PV + ...
params.c_inv.ESS_p * x_ESS_p + params.c_inv.ESS_e * x_ESS_e + ...
params.c_inv.MT * x_MT;
Objective = C_inv + theta;
Constraints = [];
% 第一阶段变量约束
Constraints = [Constraints, 0 <= x_WT <= params.x_max.WT];
Constraints = [Constraints, 0 <= x_PV <= params.x_max.PV];
Constraints = [Constraints, 0 <= x_ESS_p <= params.x_max.ESS_p];
Constraints = [Constraints, 0 <= x_ESS_e <= params.x_max.ESS_e];
Constraints = [Constraints, 0 <= x_MT <= params.x_max.MT];
% 为每个已知场景添加运行约束
for k = 1:K
xi_pv = scenario_set{k}.xi_pv;
xi_wt = scenario_set{k}.xi_wt;
% 功率平衡约束
Constraints = [Constraints, ...
P_wt_generate(d, t) + xi_pv(d,t) * eta_pv * x_PV / 1000 ... % 这里注意单位统一
+ P_MT{k}(d,t) + P_ESS_d{k}(d,t) + P_gb{k}(d,t) == ...
params.P_load(d,t) + P_ESS_c{k}(d,t) + P_gs{k}(d,t) + P_curt{k}(d,t)];
% 风光出力上限约束
Constraints = [Constraints, ...
0 <= xi_wt(d,t) * x_WT * params.eta_wt / 1000 - P_wt_curtail{d}(d,t)];
% 储能约束
Constraints = [Constraints, 0 <= P_ESS_c{k}(d,t) <= x_ESS_p];
Constraints = [Constraints, 0 <= P_ESS_d{k}(d,t) <= x_ESS_p];
% SOC递推约束
if t == 1
Constraints = [Constraints, SOC{k}(d,t) == params.SOC_init * x_ESS_e * 0.2 + ...
eta_ch * P_ESS_c{k}(d,t) - P_ESS_d{k}(d,t) / eta_dis];
else
Constraints = [Constraints, SOC{k}(d,t) == SOC{k}(d,t-1) + ...
eta_ch * P_ESS_c{k}(d,t) - P_ESS_d{k}(d,t) / eta_dis];
end
Constraints = [Constraints, 0 <= SOC{k}(d,t) <= x_ESS_e];
% 燃气轮机约束
Constraints = [Constraints, 0 <= P_MT{k}(d,t) <= x_MT];
% 电网交互约束
Constraints = [Constraints, 0 <= P_gb{k}(d,t) <= params.P_grid_max];
Constraints = [Constraints, 0 <= P_gs{k}(d,t) <= params.P_grid_max];
% 运行成本定义
C_op = sum(sum(price_gas / LHV_gas / eta_MT * P_MT{k})) + ...
sum(sum(params.price_buy * P_gb{k})) - ...
sum(sum(params.price_sell * P_gs{k})) + ...
sum(sum(params.penalty_curtail * P_curt{k}));
% theta约束:每个场景的运行成本不超过theta
Constraints = [Constraints, theta >= C_op];
end
% 求解主问题
ops = sdpsettings('solver', 'gurobi', 'verbose', 0);
optimize(Constraints, Objective, ops);
% 提取结果
sol_x.x_WT = value(x_WT);
sol_x.x_PV = value(x_PV);
sol_x.x_ESS_p = value(x_ESS_p);
sol_x.x_ESS_e = value(x_ESS_e);
sol_x.x_MT = value(x_MT);
end
请注意,上面代码片段里我用注释跨过了风力发电的详细公式以保持篇幅可控,实际实现时请根据构好的风电出力公式补全。
这里有几个容易出错的地方需要特别提醒:
第一是单位一致性。在容量配置模型里,有些地方的单位是kW,有些地方是MW,转换之间稍不留神就会出现容量相差1000倍的错误。我建议全部统一用kW和kWh作为基础单位,光伏出力系数标幺后乘以装机容量即是kW,电价单位是元/kWh,成本出来后自然就是元。如果从哪里复制了一段单位不一致的代码,调试时往往是最难定位的bug之一。
第二是储能SOC的初始值处理。容量配置模型中,储能SOC初值一般取额定容量的某个比例(比如20%),这个比例的选择会影响储能每天的可调度能力。在实际代码中需要注意SOC上下限的定义是相对额定能量还是相对可用能量。(0 \le SOC_t \le x_{ESS,e}) 其实是相对额定能量的表达,如果希望控制储能深度放电以保护电池寿命,需要在SOC下界乘一个系数,比如0.1:
[
0.1 \cdot x_{ESS,e} \le SOC(t) \le 0.9 \cdot x_{ESS,e}
]
这个细节虽然简单,但对电池寿命影响很大。有些论文会采用0.1到0.9的SOC运行范围来模拟DOD(放电深度)限制,实际工程中这样做也跟电池厂商的质保条款更匹配。
第三是设备出力上限约束中 (\eta) 的引入。有些模型里直接把风光出力的归一化系数乘以装机容量作为最大出力,没有单独乘效率系数。但从实际运行角度看,风机的功率曲线、光伏组件的光电转换、逆变器效率、线损等都会让实际并网功率低于理论值。我用 (\eta_{WT}) 和 (\eta_{PV}) 这两个整体效率系数来统一考虑这些损耗。在论文中如果不加这两个系数,相当于默认所有转换效率都是100%,会高估可再生能源的出力能力。
5.5 子问题求解:从对偶到单层重构
子问题求解是整个代码中最核心也最容易出错的部分。按照前面4.2和4.3节的分析,需要把max-min问题通过强对偶转成一个单层的max问题。
我直接给出一个经过简化处理的实现框架。假设第二阶段问题经过对偶变换后得到如下形式:
matlab复制function [xi_worst, obj_val] = solve_subproblem(x, params)
% 求解子问题,返回最恶劣场景和对应目标值
% x - 第一阶段决策变量,结构体包含x_WT, x_PV, x_ESS_p, x_ESS_e, x_MT
% 读取当前容量配置
x_WT = x.x_WT;
x_PV = x.x_PV;
x_ESS_p = x.x_ESS_p;
x_ESS_e = x.x_ESS_e;
x_MT = x.x_MT;
% 定义对偶变量
lambda = sdpvar(params.D, params.T, 'full'); % 功率平衡约束对偶
mu_lb_pv = sdpvar(params.D, params.T, 'full'); % 光伏出力下限对偶
mu_ub_pv = sdpvar(params.D, params.T, 'full'); % 光伏出力上限对偶
% ... 其他对偶变量类似
% 不确定量(这里是决策变量)
xi_pv = sdpvar(params.D, params.T, 'full');
xi_wt = sdpvar(params.D, params.T, 'full');
Constraints = [];
% 不确定集约束(预算约束)
Constraints = [Constraints, params.xi_pv_min <= xi_pv <= params.xi_pv_max];
Constraints = [Constraints, params.xi_wt_min <= xi_wt <= params.xi_wt_max];
Constraints = [Constraints, sum(sum((xi_pv - params.xi_pv_ref))) <= params.Gamma_pv];
Constraints = [Constraints, sum(sum((xi_wt - params.xi_wt_ref))) <= params.Gamma_wt];
% 对偶约束
Constraints = [Constraints, lambda >= 0];
Constraints = [Constraints, lambda <= params.price_buy + M * (1 - z_buy)];
% ... 这里根据对偶推导结果逐一添加
% 目标函数(合并后为最大化问题)
Objective = ...; % 从对偶推导中得到的表达式
ops = sdpsettings('solver', 'gurobi', 'verbose', 0);
optimize(Constraints, -Objective, ops); % 注意YALMIP默认最小化
xi_worst.xi_pv = value(xi_pv);
xi_worst.xi_wt = value(xi_wt);
obj_val = value(Objective);
end
由于对偶推导的完整表达式比较复杂,这里不展开所有代数细节。核心的推导逻辑我在4.2节中已经讲清楚了——把内层的min问题改写成对偶max,再与子问题外层的max合并为一个单层max问题。要顺利走到这一步,前提是第二阶段运行问题确实是线性规划。
如果你的模型中考虑储能SOC的整数状态(比如储能只有闲置/充电/放电三种状态),那第二阶段就会变成MILP,问题性质就发生了变化。此时强对偶不再成立,必须在子问题内部嵌入一个求解器来处理内层MILP,复杂度会显著上升。做第一版代码时建议先把这些离散状态放开,用SOC线性递推+充放电功率互斥的线性化约束来实现相同的功能。
充放电互斥可以用下面的线性约束实现(避免同时充电又放电的人工低效解):
[
P_{ESS,c}(t) \le M \cdot z(t)
]
[
P_{ESS,d}(t) \le M \cdot (1 - z(t))
]
其中 (z(t)) 是二进制变量,(M) 取储能额定功率的上界。如果不想引入二进制变量,也可以用 (P_{ESS,c}(t) \cdot P_{ESS,d}(t) = 0) 这种互补约束,但求解器处理起来会比较困难,一般还是建议用二进制变量的大M表达。
5.6 主循环:两阶段迭代的完整流程
在 main.m 中,CCG迭代主循环的完整逻辑如下:
matlab复制%% CCG主循环
x = struct('x_WT', 500, 'x_PV', 500, 'x_ESS_p', 200, 'x_ESS_e', 400, 'x_MT', 300);
% 初始场景:用于启动算法,一般用预测场景(期望值场景)
scenario_set = {};
scenario_set{1} = struct('xi_pv', params.xi_pv_ref, 'xi_wt', params.xi_wt_ref);
LB = -inf;
UB = inf;
iter = 1;
while (UB - LB) / max(1, abs(UB)) > epsilon && iter <= max_iter
fprintf('CCG迭代 %d 开始...\n', iter);
% 求解主问题,得到容量候选方案和新的下界
[model, sol_x] = build_master_problem(scenario_set, params);
LB = value(model.objective);
% 求解子问题,在当前候选容量方案下找最恶劣场景
[xi_worst, obj_sub] = solve_subproblem(sol_x, params);
% 计算上界:投资成本 + 子问题返回的最恶劣场景运行成本
C_inv_current = params.c_inv.WT * sol_x.x_WT + ...
params.c_inv.PV * sol_x.x_PV + ...
params.c_inv.ESS_p * sol_x.x_ESS_p + ...
params.c_inv.ESS_e * sol_x.x_ESS_e + ...
params.c_inv.MT * sol_x.x_MT;
UB = C_inv_current + obj_sub;
% 检查是否已找到重复场景
is_duplicate = false;
for k = 1:length(scenario_set)
if norm(scenario_set{k}.xi_pv - xi_worst.xi_pv, 'fro') < 1e-6 && ...
norm(scenario_set{k}.xi_wt - xi_worst.xi_wt, 'fro') < 1e-6
is_duplicate = true;
break;
end
end
if ~is_duplicate
% 将新场景添加到主问题的场景集
scenario_set{end+1} = xi_worst;
fprintf('添加场景 %d,当前场景总数: %d\n', length(scenario_set), length(scenario_set));
else
% 如果场景重复,说明子问题已经无法产生新信息
% 这时可以认为已经收敛
fprintf('场景重复,提前终止。\n');
break;
end
fprintf('Iter %d: LB = %.4f, UB = %.4f, gap = %.4f%%\n', ...
iter, LB, UB, abs(UB - LB) / abs(UB) * 100);
iter = iter + 1;
end
% 输出最优方案
fprintf('最优容量配置结果:\n');
fprintf('风机容量:%.1f kW\n', x.x_WT);
fprintf('光伏容量:%.1f kW\n', x.x_PV);
fprintf('储能额定功率:%.1f kW\n', x.x_ESS_p);
fprintf('储能额定能量:%.1f kWh\n', x.x_ESS_e);
fprintf('燃气轮机容量:%.1f kW\n', x.x_MT);
这段主循环代码的流程基本是CCG的教科书标准流程,但在实际调试中需要注意几个关于“收敛判据”的细节:
第一个数是LB的更新逻辑。主问题的目标值由于只包含有限个场景,是原问题的下界;随着场景逐步添加,主问题可行域逐步收紧,LB应该单调不减。如果你发现LB在下降,十有八九是主问题的求解没有真正达到全局最优,检查一下是不是有个别二进制变量没有被正确声明,或者约束写漏了一条。
第二个需要注意的是UB的计算口径。UB应该是“当前容量方案的真实总成本”,也就是投资成本加上所有可能场景中的最大运行成本。在子问题被成功求解到最优的前提下,这个上界才是真实有效的。如果子问题的求解器返回的MIP gap不是0,那么这里算出来的UB很可能偏低,会误导收敛判断。
我在实际测试中就遇到过一种情况:主问题不断添加场景,但LB和UB之间的gap卡在3%左右怎么都降不下去。后来我逐轮打印了子问题返回的最恶劣场景,发现场景在几个非常相似的出力模式之间反复切换,每次只有非常微小的数值差异。原因是我的不确定集预算参数 (\Gamma) 设得太大,导致不确定集内部“几乎同样恶劣”的场景非常多,CCG需要非常多的迭代才能覆盖所有接近最优的场景。这种情况下不必真的把所有场景都枚举完,用gap在2%以内作标准,或者干脆降低 (\Gamma),才是更工程化的选择。
5.7 主问题与子问题的变量传递细节
一个不常被提及但非常影响代码稳定性的细节是主问题和子问题之间的变量传递。主问题每次迭代会生成大量YALMIP变量,如果直接把这些变量清零然后重建,MATLAB内存会在十几轮迭代后显著膨胀。我在代码中使用了一种简单有效的做法:每轮迭代结束后,只保留需要的数值解(用 value() 提取),原始YALMIP变量通过 clear 清除。
matlab复制function clear_yalmip_model(model)
% 清理YALMIP模型占用的内存
try
if ~isempty(model)
delete(model);
end
catch
end
end
另外,如果你用YALMIP+GUROBI的配置,建议在sdpsettings里设置好求解器的随机种子和容忍度:
matlab复制ops = sdpsettings('solver', 'gurobi', 'verbose', 0);
ops.gurobi.MIPGap = 1e-4;
ops.gurobi.FeasibilityTol = 1e-6;
ops.gurobi.OptimalityTol = 1e-6;
ops.gurobi.TimeLimit = 300;
这些参数的设置对大规模问题的收敛速度影响非常大。我见过不少论文复现代码里只写了solver参数,其他全部用默认值,结果有的模型需要跑几个小时才收敛。设了一个比较紧的MIPGap之后,很多时候求解时间能缩短一半以上。
6. 收敛性分析与结果验证的实战经验
6.1 怎么判断结果是真的收敛了
CCG算法收敛的标准是上下界间隙足够小。但在实际使用中,如果只看gap的数字,很容易被表面的收敛假象欺骗。
我常用的验证手段是,在CCG最终输出的容量方案下,随机抽取几十组历史上真实发生的风光出力场景,用这些场景做一次纯运行模拟(也就是第二阶段优化),统计每组场景下的运行成本。如果模拟结果中出现了比CCG子问题找出的最恶劣场景还要高的运行成本,说明不确定集或者子问题的求解存在着盲区。
这个做法其实模拟的是“方案交付前的大规模情景测试”,它比单纯看gap值要可靠得多。我建议做容量配置优化的同学都养成这个习惯——不管你的算法收敛得有多漂亮,最终都要回到历史真实数据里去测试方案。如果十几组随机测试都会触发切负荷或者导致运行成本异常偏高,说明你的不确定集参数设得太“自信”,需要加大 (\Gamma) 或者调整上下界范围。
6.2 从结果倒推不确定集参数是否合理
一个很容易犯的错误是:用一套历史数据同时构造不确定集和验证方案。
举个具体的例子。假设你的历史数据包含过去三年的逐小时风光出力数据,如果直接用这三年的数据统计出风光出力预测误差的上下界,然后构造不确定集,再用同一批数据来验证CCG给出的方案,这其实是“用测试集当训练集”,验证结果会显得过于乐观。正确的做法是留出一部分数据(比如最后一年的数据)不参与不确定集构建,等模型训练完成后再拿这部分独立数据来测试方案。
这和机器学习里训练集/测试集分离的道理一模一样。我在自己的项目中一般会按7:3的时间比例切分历史数据,前70%用来统计不确定量上下界和预测误差分布,后30%用来做方案的场景测试。如果你所在的项目数据量本身不多,可以考虑用bootstrap重抽样来扩展测试集,确保覆盖足够的极端情况。
6.3 储能容量结果里的一些反直觉现象
做容量配置跑出结果后,有时候会发现一些反直觉的现象。举个例子,我曾经跑过一组参数,发现当预算参数 (\Gamma) 从10增加到15时,最优储能容量不增反降。一开始以为代码有bug,排查了很久,后来发现这其实是合理的:更大的 (\Gamma) 意味着风光不确定范围更大,为了应对更极端的低风光场景,微网反而会配置更多的燃气轮机来做“兜底电源”,而储能在极端低风光场景下的作用有限(因为没有可再生能源给它充电),所以储能的边际价值反而下降了。
这说明微网容量配置是一个多设备强耦合的系统,各个设备的容量不是独立决定的。如果你在结果中看到某类设备容量随不确定程度的变化呈非单调关系,不要急着怀疑代码,先分析一下设备之间的互补关系。这里可以把储能理解为“调节器”,它需要依赖可再生能源或者电网来充电,在极端的低风光场景下本身的调节空间有限。
6.4 数值结果稳定性的检查
CCG算法的数值稳定性是另一个需要重视的问题。我遇到过一个很头疼的情况:同一套参数,跑三次代码得到三个不同的结果。除了随机数种子导致的求解器路径差异外,最常见的原因是某些约束使用了大M法线性化,而M值取小了或者取大了,导致不同迭代轮次的可行域出现细微变化。
在代码里埋一个数值自检的步骤会很有帮助。我通常在每次主问题求解后,检查一下所有与第一阶段变量相关的约束是否被“卡在边界”上。如果某个容量变量确实达到了上限 (x_i^{max}),需要确认一下这个上限是不是真实约束(比如场地条件限制),而不是因为建模时上限值设得太小导致的结果。
另外,对结果做敏感性分析也很重要。最直接的做法是,把投资成本系数上下浮动10%,重新跑一遍完整的CCG迭代,记录容量配置结果的变化。如果某个设备的配置容量对成本系数非常敏感,说明该设备的投资经济性处于临界状态,这时在工程上需要对成本参数做更仔细的调研。如果某个设备的配置容量对成本系数变化几乎无响应,说明该设备在这个微网系统中的角色主要是“刚性支撑”,其容量选择更多受可靠性约束驱动。这类敏感性分析结论写到项目报告里会比单纯给出一组“最优解”要有说服力得多。
7. YALMIP+Gurobi环境下常见报错与调优速查
7.1 报错“No suitable solver”的排查
YALMIP报这个错通常有三种原因:求解器没安装好、模型包含求解器不支持的类型、YALMIP没有检测到对应的求解器。
处理步骤:
- 运行
yalmiptest检查YALMIP与求解器的连接状态。 - 确认模型的变量类型(连续、二进制、整数)是否在你的求解器支持范围内。微网容量配置中因为涉及储能充放电状态变量,主问题通常是MILP,需要使用GUROBI或CPLEX这类商业求解器。如果只用LINPROG,遇到二进制变量就会报这个错。
- 确认有没有在
sdpsettings里误设了'solver'参数,比如拼错了Gurobi的大小写。
7.2 收敛缓慢时的参数调优路径
如果你的CCG迭代跑了很多轮但gap下降很慢,先检查下面几件事:
- 不确定集预算参数 (\Gamma) 是否太大。(\Gamma)过大导致子问题的搜索空间很大,每一次找到的“新场景”跟之前的场景差别可能很微小,迭代自然缓慢。可以试着先减小 (\Gamma),观察收敛速度的变化。
- 主问题的MIP求解精度。如果你的主问题本身就是MILP,求解器的默认MIPGap可能停在0.01%或者更松,这会直接影响LB的精度。建议对主问题单独设置更紧的求解容忍度。
- 子问题的求解耗时。如果子问题耗时远大于主问题,可能是因为对偶后引入了大量的二进制变量。检查不确定集是否被离散得过细。我曾尝试把光伏出力从100档离散降到20档,子问题求解时间缩短了将近一半,而最恶劣场景的识别结果几乎没变化。
7.3 Gurobi求解器参数推荐
在我自己的项目中,经过多次调参后比较稳定的GUROBI参数组合如下:
matlab复制ops = sdpsettings('solver', 'gurobi', 'verbose', 1);
ops.gurobi.MIPGap = 1e-4; % MIP相对最优性间隙
ops.gurobi.MIPGapAbs = 1e-4; % MIP绝对最优性间隙
ops.gurobi.FeasibilityTol = 1e-6;
ops.gurobi.IntFeasTol = 1e-6;
ops.gurobi.Threads = 8; % 多核并行计算
如果你的机器内存不是瓶颈,将Threads设到8或以上对加速求解很有帮助。另外,MIPGap和MIPGapAbs不要同时设得太紧,否则求解器会因为追求过高的精度在最后阶段浪费时间。
7.4 与历史论文结果的复现对比
很多同学做这类项目,最后都需要跟已发表论文的结果做对比。这一步最容易遇到的坑就是基准参数不一致。
比如有的论文用等年值法折算投资成本,资金回收率取0.1;有的论文直接用一次性投资成本不做年限折算。这两种口径下算出来的“最优配置”可能完全不同。在做对比之前,先花时间统一参数口径,比花时间精调算法更值得。这也是为什么我建议在代码注释里把所有参数的单位、时间基准、折算方式写清楚。回头对比的时候,能省掉一大笔时间。
8. 从单典型日到多典型日的扩展与实现
8.1 为什么要用多个典型日
我在最早的模型中只使用了一个典型日(24个时段)来代表全年的运行状况,这样模型规模小、收敛快,但结果往往不够稳健。用多个典型日,尤其是同时包含夏季高温高负荷日、冬季低温高负荷日、大风日、连续阴雨天等典型场景,可以让容量配置方案在更广泛的实际场景下都表现良好。
在严格意义上,两阶段鲁棒优化处理的是连续不确定集,典型日法的场景数量不需要也不可能覆盖所有真实日。但通过典型日的合理选取,可以让风光出力的不确定性在不同典型日之间存在一定的差异化——这对确定储能和燃气轮机的容量很有帮助。
8.2 典型日之间的耦合处理
多典型日扩展时有一个非常关键的选择:典型日之间的SOC是否解耦。
如果各典型日之间SOC完全解耦(即每个典型日的起始SOC固定为20%),那每个典型日可以独立求解,模型规模大约是单典型日的D倍,处理起来很方便。但这种处理方式低估了储能在连续多日间的能量搬移能力。
如果要建模连续多日间的储能运行,需要引入“日与日之间的SOC衔接约束”。例如第d天结束时的SOC等于第d+1天开始时的SOC。这个约束使所有典型日在储能SOC变量上耦合在一起,模型规模显著增加,而且CCG子问题的对偶结构变得更为复杂。
我的建议是:第一版先用SOC解耦版本,把算法和代码流程跑通;后续如果发现储能容量结果明显偏高或偏低,再考虑是否引入日间SOC耦合。
8.3 代码中的典型日循环实现
在多典型日模式下,需要把循环写到典型日这一维度。一个推荐的向量化思路是用三维数组(典型日x时段x场景)来存储所有运行变量,然后在主问题约束中用适当的索引一次性写出。好处是不需要写显式的双层循环,YALMIP的建模速度会快很多。
如果坚持用二维变量(时段x场景)加显式循环来写,代码可读性好,但当典型日数量和场景数量同时上升后,YALMIP建模时间会线性上升。我实测过,当运行变量总数超过20万个时,逐条添加约束的耗时已经到了让人难以忍受的地步——这时建议换用向量化表达,充分发挥YALMIP的批量约束处理能力。
8.4 一个容易混淆的细节:典型日权重
在多典型日模型中,运行成本要不要乘以典型日的权重系数(代表该典型日代表的天数,比如夏季典型日代表全年的90天,权重就是90/365)?
答案是:如果目标函数是年化总成本,而典型日代表的是不同季节的典型运行状态,那么运行成本必须乘上权重系数,否则会低估或高估某些季节对总成本的影响。
在CCG框架中,这个权重影响体现在主问题的运行成本和子问题的目标函数里。我建议把权重系数单独放在一个数组里:
matlab复制% 典型日权重,加起来等于1
day_weight = [0.25, 0.25, 0.25, 0.25];
然后在计算运行成本时:
matlab复制C_op = sum(sum(day_weight' * (price_gas / LHV_gas / eta_MT * P_MT))) / 365 * 365;
逻辑上相当于把每个典型日的运行成本折算到全年。如果不乘权重,等于是把所有典型日按同等重要度来对待,结果会偏向过渡季的典型日。我在代码中建议把这一点做成一个明显的开关参数,并写清注释。
9. 结果可视化与方案解读规范
9.1 需要输出的核心图表
做容量优化配置的项目,最终交付给业主或者导师/客户的往往不只是几个数字,而是一组能讲清楚方案逻辑的图表。我习惯输出下面几类图:
第一类是容量配置对比柱状图。包括确定性优化、单阶段鲁棒、两阶段鲁棒三种方法下的各类电源配置容量对比。这张图能直观看清楚不同风险偏好下容量的变化趋势。
第二类是CCG收敛曲线。横轴是迭代次数,纵轴是上下界值以及gap变化。这张图证明了你的求解过程是收敛的,并且能看出算法在第几轮迭代后进入稳定阶段。
第三类是最恶劣场景下的运行曲线。挑出子问题找到的最恶劣风光场景,画出该场景下微网内各设备的出力曲线、储能SOC曲线和电网交互功率曲线。这张图用来验证在极端场景下系统能不能维持功率平衡。
第四类是敏感性分析图。探究不同 (\Gamma) 值或者不同电价水平对最优容量的影响。这类图在工程决策中特别有参考价值,因为它展示了方案在不同外部条件下的适应性。
9.2 结果解读中避免的误区
第一个误区是想当然地认为可再生能源装机容量越大越好。在成本优化目标下,光伏和风机的容量不仅取决于资源条件,还取决于微网能不能在发电高峰期消纳这些电力。如果储能容量不足、电网交互上限有限,多余的电能只能低价卖出甚至弃掉,此时扩大可再生能源容量的边际收益会迅速下降。
第二个误区是忽略储能在不同应用场景下的价值差异。同一个储能系统,在以削峰填谷为主要目标的微网和以应急保障为主要目标的微网中,最优配置可能相差很大。如果你的模型里没有停电概率或可靠性约束,那储能的价值主要体现为电价套利和提升可再生能源消纳率。如果项目对供电可靠性有硬性要求,模型里需要额外添加可靠性约束,否则储能容量会被系统性低估。
9.3 可视化代码骨架
最后给一段简单的可视化代码模板,基于MATLAB自带的绘图工具。
matlab复制function plot_results(x, lb_history, ub_history, scenario_worst, params)
figure('Position', [100, 100, 1200, 500]);
% 子图1:CCG收敛曲线
subplot(1, 3, 1);
iterations = 1:length(lb_history);
plot(iterations, lb_history, 'b-o', 'LineWidth', 1.5);
hold on;
plot(iterations, ub_history, 'r-s', 'LineWidth', 1.5);
xlabel('迭代次数');
ylabel('成本(元)');
legend('LB(主问题下界)', 'UB(子问题上界)', 'Location', 'best');
grid on;
title('CCG收敛曲线');
% 子图2:容量配置柱状图
subplot(1, 3, 2);
categories = {'风机', '光伏', '储能功率', '储能能量', '燃气轮机'};
capacity = [x.x_WT, x.x_PV, x.x_ESS_p, x.x_ESS_e, x.x_MT];
bar(capacity);
set(gca, 'XTickLabel', categories);
ylabel('配置容量(kW 或 kWh)');
title('最优容量配置方案');
% 子图3:最恶劣场景下的典型日运行曲线
subplot(1, 3, 3);
t = 1:params.T;
plot(t, scenario_worst.xi_pv(1,:), 'g-', t, scenario_worst.xi_wt(1,:), 'b--');
xlabel('时段(h)');
ylabel('归一化出力系数');
legend('光伏出力系数', '风电出力系数', 'Location', 'best');
grid on;
title('最恶劣场景出力曲线');
end
画图的核心目的不是好看,而是辅助判断结果合理性。如果画出收敛曲线后看到UB在后期仍明显波动,那这个结果要再多做几轮验证再交付。
在两阶段鲁棒优化这个领域,模型代码本身只是载体,真正困难的部分在于:如何把实际问题抽象成能被算法处理的数学结构,以及如何在迭代中理解和控制每一个参数对结果的影响。希望这篇文章能把我在实践中积累的这些判断和经验传递出来,让你在复现或应用时少走一些弯路。
