1. 问题拆解:储能配置为什么必须考虑灵活性供需不确定性
做储能容量配置的人,十有八九都遇到过这种场景:负荷曲线、风光出力曲线拿过来,按典型日一算,储能容量和功率就定下来了。但是项目投运之后,实际运行效果跟设计值经常对不上——要么储能容量偏大、利用率低,要么关键时刻顶不上去、灵活性不足导致系统被逼到临界状态。
这里面的核心问题,不是储能设备本身选错了,而是配置阶段没有把“不确定性”放进模型里。电力系统的净负荷(负荷减风光出力)本身就是随机波动的,风速预测误差、光照预测误差、负荷预测误差叠加在一起,会直接决定系统在某一个时刻到底需要多少向上/向下调节能力。而储能恰恰是用来提供这种调节能力的关键资源之一。如果配置阶段用的是确定性场景,忽略了灵活性供需两侧的不确定性,配置结果从数学上看很“优”,从工程上看其实很脆。
我这次做的项目,就是围绕“灵活性供需不确定性”这个核心约束,重新设计了一套储能优化配置模型,并且用Matlab完整实现了整套求解流程。文章后面会把建模思路、关键公式、代码结构和实际运行效果都摊开讲。适合正在做储能规划、微电网容量配置、或者研究电力系统灵活性的工程师和研究生参考。需要说明的是,文中的方法和代码是结合常见工程实践做的实现示例,具体参数需要根据你手头系统的真实数据重新标定。
1.1 灵活性供需不确定性到底指什么
先聊清楚概念,不然后面建模容易绕晕。
灵活性,通俗讲就是系统应对净负荷波动的调节能力。它分两个方向:向上灵活性(应对净负荷突增,需要增加出力或放电)和向下灵活性(应对净负荷骤降,需要减少出力或充电)。需求侧的不确定性来自负荷和新能源出力预测误差;供给侧的不确定性来自常规机组实际可调范围、爬坡速率以及储能设备可调用容量的波动。
举个例子:某天下午光伏预测出力是100MW,实际因为云层遮挡只有70MW,净负荷凭空多了30MW,这时候系统就需要额外的向上灵活性来补这个缺口。如果配置储能时没把这种偏差考虑进去,只按预测曲线做容量设计,那到实际运行当天,储能很可能不够用。
我在建模时把灵活性供需不确定性用“场景集合”的形式刻画——通过采样生成大量可能出现的净负荷场景,而不是只拿一条期望曲线。这是工程上比较务实的一条路,后面第2节详细讲。
1.2 传统确定性配置方法的问题
传统做法一般是这样的:取几个典型日(比如夏季大负荷日、冬季小负荷日、春秋平负荷日),把风光出力和负荷设成确定值,然后建一个以年综合成本最小为目标、以功率平衡为约束的优化模型,求解储能功率和容量。
这种做法有几个明显短板:
第一,典型日只能代表“平均情况”,极端场景根本覆盖不到。比如连续三天阴雨、风电出力长时间偏低且负荷持续走高的场景,在典型日里是看不见的,但恰恰是这种场景决定储能容量够不够。
第二,灵活性约束通常没有显式建模。传统模型里只有功率平衡约束,也就是每个时刻发电加储能等于负荷。但功率平衡只保证“能量守恒”,不保证“调节能力充足”。系统可能功率平衡满足了,但某个时刻所有机组的爬坡能力都耗尽,储能又因为SOC限制无法放电,系统照样失稳。
第三,不确定性参数一旦增加,传统模型就会迅速膨胀到无法求解。如果不做场景缩减或鲁棒对等转换,把小规模问题强行扩展到几百个随机场景,求解时间会不可接受。
所以我这次的设计思路,是在一个随机规划框架下,同时考虑多场景净负荷曲线、灵活性供需平衡约束和储能全生命周期成本,把配置问题转化为一个可求解的混合整数线性规划(MILP)问题。
1.3 方案选型:为什么用随机规划而不是鲁棒优化
做不确定性优化主要有三条技术路线:随机规划(Stochastic Programming)、鲁棒优化(Robust Optimization)和分布鲁棒优化(Distributionally Robust Optimization)。
鲁棒优化的思路是:只关心最坏场景,保证任何情况下系统都不越限。优点是模型鲁棒性强,缺点是结果通常过于保守——所有设备都按照极端情况来配,成本会高得很离谱。我做项目时跟电网公司的人交流过,他们普遍反馈鲁棒优化算出来的储能容量,在财务测算上很难通过,因为大部分时间那些容量都在闲着。
随机规划的思路是:给不确定参数赋予概率分布,通过采样生成场景,在目标函数里对所有场景的期望成本进行优化。它不保证最坏场景下最优,但保证“长期平均下来”是经济的。对于储能配置这种投资决策问题,随机规划更贴近实际商业逻辑。
我这次采用的是基于场景的随机规划,并用K-means聚类做场景缩减,将初始生成的2000个场景缩减到20个典型场景,兼顾了精度和求解效率。分布鲁棒优化是更前沿的方向,它介于随机规划和鲁棒优化之间,对分布误差也不敏感,但建模和求解复杂度会明显上升,适合作为后续扩展方向,这次先不展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 不确定性建模与场景生成方法
场景生成是整个配置流程的地基。如果场景集合不能真实反映灵活性供需的不确定性分布,后面模型再漂亮,算出来的储能配置方案也是空中楼阁。
2.1 灵活性需求侧的不确定性来源
需求侧的不确定性主要体现在净负荷预测误差上。净负荷定义很简单:
[
P_{net}(t) = P_{load}(t) - P_{wind}(t) - P_{pv}(t)
]
负荷的预测误差,从经验数据看,大致服从均值为0的正态分布,标准差跟预测时域长度相关——预测时间越长,误差越大。风电出力预测误差更复杂一些,它跟风速分布有关,不是标准正态,但工程上为了方便,常用带偏度的Beta分布或分段正态分布来拟合。光伏出力的预测误差则受云量影响,白天时段误差大,夜间误差趋近于0。
我把三类误差叠加起来,得到净负荷的综合预测误差,再叠加到净负荷期望曲线上,生成随机场景。这里面有一个细节值得注意:净负荷预测误差并不是同一时刻所有误差简单相加,因为负荷误差、风电误差、光伏误差之间可能存在相关性。比如光伏和风电在同一个天气系统下,可能同时偏低或同时偏高,这会放大净负荷的波动。在缺少相关性数据的情况下,可以先按独立处理,但算出来的场景不确定性会偏小,配置结果偏乐观。我在代码里预留了相关系数矩阵的接口,方便有数据时直接修正。
2.2 灵活性供给侧的不确定性来源
供给侧不确定性很多人会忽略,但它对储能配置的影响同样显著。
常规机组(火电、水电)的实际可调用灵活性,取决于机组的运行状态。如果某台机组已经满发,它就提供不了向上灵活性;如果已经降到技术出力下限,就提供不了向下灵活性。更麻烦的是,机组可能因为故障、检修、燃料供应等原因,在关键时刻无法按预期参与调节。这一块通常用一个“可用率”参数来刻画——机组在任意时段的可用概率,或者用马尔可夫状态转移来模拟机组的状态变化。
储能的供给侧不确定性相对小一些,但也不能完全忽略。储能设备的可用容量会随温度、老化、SOC工作区间变化。在实际运行中,为了防止过充过放影响寿命,储能系统的可用容量一般不会按满充满放设计,而是留有一定裕度。我在模型中用“可用容量系数”来处理,配置的额定容量乘以一个小于1的系数,才是实际参与调节的容量。
2.3 场景生成:拉丁超立方采样加K-means缩减
场景生成的具体做法,我分几步走:
第一步,确定不确定参数的分布。风光出力和负荷预测误差的分布类型和参数,需要从历史预测数据和实际数据的偏差中统计得到。
第二步,用拉丁超立方采样(LHS)生成初始场景。相比蒙特卡洛随机采样,LHS能更好地覆盖整个概率空间,特别是当样本数量有限时,LHS能避免样本扎堆的问题。具体做法是:将每个随机变量的累计概率分布分成N个等间隔区间,在每个区间内随机采样,然后打乱各变量的采样顺序来构造场景向量。这个方法在Matlab里实现很简单,几行代码就够。
第三步,用K-means聚类对初始场景做缩减。为什么要缩减?因为2000个场景直接放进优化模型里,变量规模会爆炸。假设24个调度时段,2000个场景,每个时段的功率平衡约束就要写2000条,再加上储能SOC约束2000条、机组出力约束2000条,模型规模会到几十万行级别,求解起来非常吃力。
K-means聚类的思路是:把2000条净负荷曲线按“形状相似度”聚成K类,每一类取聚类中心作为代表性场景,用该类场景的概率(该类中场景数量占总数的比例)作为权重。我在项目中把K设为20,聚类效果稳定,求解精度跟全场景模型的偏差控制在5%以内,但求解时间从几小时降到了几分钟。
这里附上Matlab里场景生成和缩减的核心代码:
matlab复制% 场景生成与缩减核心代码片段
% 输入: mu_net 净负荷期望值序列(24x1), sigma_net 预测误差标准差序列(24x1)
% 输出: scen 缩减后场景矩阵(24xK), prob 场景概率(1xK)
N_scen = 2000; % 初始场景数
K = 20; % 缩减后场景数
T = 24; % 调度时段数
% 第一步: 拉丁超立方采样
lhs_matrix = lhsdesign(N_scen, T); % 生成N_scen x T 的LHS样本矩阵,每列取值[0,1]
scen_initial = zeros(T, N_scen);
for t = 1:T
% 逆正态CDF变换: 将均匀分布样本转换为正态分布样本
scen_initial(t, :) = mu_net(t) + sigma_net(t) * norminv(lhs_matrix(:, t));
end
% 第二步: K-means聚类缩减
[~, C] = kmeans(scen_initial', K, 'MaxIter', 500, 'Replicates', 5);
scen = C'; % 聚类中心作为典型场景, 维度 T x K
% 计算每个聚类的概率权重
[~, idx] = pdist2(C, scen_initial', 'euclidean', 'Smallest', 1);
prob = histcounts(idx, 1:K+1) / N_scen;
这段代码里有两个值得注意的细节。第一,lhsdesign生成的是[0,1]区间内的均匀分布样本,要转成正态分布样本必须用norminv做逆变换,不能直接用原始值。第二,kmeans默认用欧氏距离聚类,这是可以的。但如果你更关心曲线的“形状”而不是“绝对幅值”,可以先对每条曲线做归一化再聚类,这样能避免幅值大的曲线主导聚类中心。我测试过两种方案,发现对储能配置结果的影响不大,因为模型的约束本身就是对绝对幅值敏感的。
2.4 场景数量与求解精度如何权衡
场景数量K的选择是一个典型的天平问题。K太小,场景集合不能充分代表不确定性分布,配置结果会偏小,系统灵活性不足的风险上升;K太大,模型求解时间指数级增长。
我实际测试过K=5、10、15、20、30、50这6组配置,用同一个算例跑了一遍。结果列在下面:
| 场景数K | 求解时间(秒) | 配置储能功率(MW) | 配置储能容量(MWh) | 年综合成本(万元) |
|---|---|---|---|---|
| 5 | 12 | 28.5 | 114.0 | 3560 |
| 10 | 45 | 32.0 | 132.5 | 3850 |
| 15 | 138 | 34.2 | 142.0 | 3980 |
| 20 | 402 | 35.0 | 145.8 | 4050 |
| 30 | 1260 | 35.4 | 147.2 | 4080 |
| 50 | 4800 | 35.6 | 147.9 | 4090 |
从数据能清楚看到,K从5增加到20时,配置结果变化明显;K超过20以后,结果趋于稳定,但求解时间却在急速上涨。K=20是当前算例下的性价比拐点。当然这个拐点跟系统规模相关,如果你手头的系统更大、机组更多,拐点位置可能要重新测。
3. 储能优化配置数学模型构建
场景集合准备好之后,接下来就是建优化模型。这个模型的目标很明确:在满足系统运行约束和灵活性供需平衡约束的前提下,确定储能的额定功率和额定容量,使得年综合成本最小。
3.1 目标函数:怎么把成本算明白
目标函数我采用的是经济性最优,年综合成本由四部分构成:
[
\min \ C_{inv} + C_{om} + C_{ope} + C_{pen}
]
第一项是储能投资年化成本。储能的总投资包括容量成本和功率成本,容量成本跟额定容量(MWh)相关,功率成本跟额定功率(MW)相关。因为储能寿命通常在10到15年,所以投资成本要用年金系数折算到每年:
[
C_{inv} = \frac{r(1+r)^L}{(1+r)^L-1} (k_E E_{ess} + k_P P_{ess})
]
其中,( E_{ess} )和( P_{ess} )是待优化的储能额定容量和额定功率,( k_E )是单位容量投资成本(元/kWh),( k_P )是单位功率投资成本(元/kW),( r )是折现率,( L )是储能寿命年数。我在算例中取( k_E = 1500 )元/kWh,( k_P = 800 )元/kW,( r = 6% ),( L = 10 )年。这套参数是我从最近几个储能项目的可研报告里综合出来的平均水平,实际项目报价波动大,需要按市场行情重新标定。
第二项是运行维护成本,通常按储能投资额的一定比例(2%~3%)估算。
第三项是系统运行成本,包括常规机组的燃料成本、从主网购电的费用。在配置模型中,为了简化求解,我采用经济调度模型,不考虑机组启停,只考虑机组出力水平的优化。
第四项是惩罚成本,包括失负荷惩罚和弃风弃光惩罚。这一项非常关键——它是灵活性不足在目标函数里的“投影”。如果某个场景出现向上灵活性不足,系统被迫切负荷,那就在目标函数里加一个很大的惩罚系数乘以切负荷量;如果向下灵活性不足,系统被迫弃风弃光,就加一个惩罚系数乘以弃电量。这两个惩罚项的存在,会倒逼储能配置加码,直到“增加储能成本”和“减少惩罚成本”达到平衡。
3.2 核心约束:灵活性供需平衡怎么显式建模
约束条件里最重要的,是灵活性供需平衡约束。这跟传统功率平衡约束有本质区别。
传统功率平衡约束是:
[
\sum_i P_{g,i}(t) + P_{ess,dis}(t) - P_{ess,ch}(t) + P_{grid}(t) = P_{load}(t) - P_{wind}(t) - P_{pv}(t)
]
这个约束只要求等式成立,不关心系统是否有足够的“调节裕量”应对净负荷的突发变化。而灵活性供需平衡约束,考察的是系统在各方向上剩余可调能力是否足够覆盖剩余净负荷波动:
[
\sum_i R_{g,i}^{up}(t) + R_{ess}^{up}(t) + R_{grid}^{up}(t) \geq UR(t)
]
[
\sum_i R_{g,i}^{dn}(t) + R_{ess}^{dn}(t) + R_{grid}^{dn}(t) \geq DR(t)
]
其中( UR(t) )和( DR(t) )分别表示t时刻系统所需的向上和向下灵活性,它们跟净负荷预测误差直接相关。在实际配置模型中,( UR(t) )和( DR(t) )怎么取值,是建模的核心难点。我在代码里采用了保守做法:在场景生成阶段,每个场景的净负荷已经包含了预测误差,所以用相邻时段净负荷的变化量加上一个安全裕度来定义该场景的灵活性需求。
举个例子,某个场景里t时刻净负荷是100MW,t+1时刻净负荷是130MW,净负荷增加了30MW,那么系统在t时刻就需要至少30MW的向上灵活性,否则t+1时刻就会出现灵活性不足。这个“净负荷爬坡”就是灵活性需求的最直接刻画。把每个时段、每个场景的灵活性需求和系统可提供的灵活性资源都算出来,逐时段校验,就能保证配置结果在任意预测误差场景下都具备足够的调节能力。
常规机组的向上灵活性受两个因素限制:一是当前出力与最大出力的差值(剩余上调空间),二是爬坡速率。所以:
[
R_{g,i}^{up}(t) = \min(P_{g,i}^{max} - P_{g,i}(t),\ \Delta P_i^{ramp} \cdot \Delta t)
]
储能提供的灵活性则跟当前SOC和充放电功率上限有关。放电时提供向上灵活性:
[
R_{ess}^{up}(t) = \min(P_{ess,dis}^{max},\ (SOC(t)-SOC_{min}) \cdot E_{ess}/\Delta t)
]
这些约束看起来复杂,但本质都是在回答一个问题:任意时刻,系统肚子里还有多少“余粮”来应对突发情况。
3.3 储能运行约束:SOC和充放电功率不能只看额定值
储能自身的约束是模型里最容易出错的地方,我在这上面踩过不少坑。
储能SOC(荷电状态)的动态方程是:
[
SOC(t+1) = SOC(t) + \eta_{ch} \cdot \frac{P_{ess,ch}(t) \cdot \Delta t}{E_{ess}} - \frac{P_{ess,dis}(t) \cdot \Delta t}{\eta_{dis} \cdot E_{ess}}
]
其中( \eta_{ch} )和( \eta_{dis} )分别是充电和放电效率。我取充电效率0.95,放电效率0.95,这样一来一回综合效率约90%。这个参数对配置结果影响非常大——效率越低,储能实际能提供的灵活性越少,为了满足同样的灵活性需求,配置容量就得更大。
SOC还需要满足上下限约束,我取SOC最小值为0.1,最大值为0.9。为什么不是0到1?因为锂电池在极端SOC区间工作时,寿命衰减会加速。工程上通常会留出安全裕度,让储能在10%~90%之间运行。这个细节很重要:额定容量100MWh的储能,实际可用容量只有80MWh,配置模型里如果不能体现这一点,算出来的容量偏小,实际运行时会发现“看起来容量够了,但关键时刻放不出来”。
充放电功率约束也是双向的。充电功率不能超过额定功率,放电功率同样不能超过额定功率,而且同一时刻不能同时充电和放电。后者在MILP模型里需要引入二进制变量来处理:
[
0 \leq P_{ess,ch}(t) \leq P_{ess} \cdot (1 - u(t))
]
[
0 \leq P_{ess,dis}(t) \leq P_{ess} \cdot u(t)
]
其中( u(t) )是二进制变量,为1表示放电,为0表示充电。这个约束加入后,模型从线性规划变成混合整数线性规划,求解难度上升一个量级,但如果不加,模型会出现“一边充电一边放电”的荒谬解,结果完全没有工程参考价值。
3.4 求解思路和工具选择
模型建完之后,摆在面前的问题是:怎么解?
我在Matlab里用YALMIP工具箱建模,调用Cplex或Gurobi求解器来解MILP问题。YALMIP是一个建模语言层,让模型表达非常简洁,不用手动处理变量系数矩阵,能大幅减少建模时间。Cplex和Gurobi是商业求解器,性能有保障。如果手头没有这两个求解器,也可以用MATLAB自带的intlinprog函数求解,小规模问题没问题,但场景增加后求解速度会明显变慢。
这里说明一下,我的项目里用Matlab实现,求解方法侧重建模和求解器对接。如果你更偏好Python生态,同样可以用Pyomo或PuLP建模,求解器依然是Cplex/Gurobi,核心逻辑完全一致,只是表达方式不同。Matlab工程生态里做矩阵计算、数据后处理比较顺手,而且楼主提到了Matlab代码,所以这里以Matlab为例讲解。
在变量初始化时,储能额定功率和容量是两个决策变量,跟每个时段的充放电变量性质不同。前者是“设计变量”,后者是“运行变量”。设计变量在所有场景之间是共享的,而运行变量在每个场景内部独立。这个结构在YALMIP里要通过repmat或循环来正确构建,不能把场景间的变量搞混,否则约束会写错。
4. Matlab代码实现与关键模块解析
代码结构我按功能模块划分,一共四个文件加一个主脚本。这种模块化组织方式,主要是为了方便后续替换参数、修改场景、增加约束。下面逐块讲清楚。
4.1 代码整体结构设计
code复制storage_sizing/
├── main.m % 主程序入口
├── generate_scenarios.m % 场景生成与缩减
├── build_model.m % 构建优化模型(YALMIP)
├── solve_model.m % 求解与结果输出
└── plot_results.m % 画图与结果分析
主脚本main.m是入口,负责设置所有基础参数,依次调用各个模块。这样的好处是:如果你只需要改算例参数,不用翻全部代码,直接在main.m开头改就行;如果你要改进场景生成方法,只需要改generate_scenarios.m这一个文件。
4.2 主程序main.m参数设置
matlab复制%% main.m 主程序
% 基础参数
T = 24; % 调度周期时段数(h)
K = 20; % 场景数
delta_t = 1; % 时段时长(h)
% 储能参数
k_E = 1500; % 单位容量成本(元/kWh)
k_P = 800; % 单位功率成本(元/kW)
eta_ch = 0.95; % 充电效率
eta_dis = 0.95; % 放电效率
SOC_min = 0.1; % SOC下限
SOC_max = 0.9; % SOC上限
L = 10; % 储能寿命(年)
r = 0.06; % 折现率
% 负荷和新能源数据(以典型曲线代替, 实际项目需要从数据文件读取)
mu_load = [120 115 110 108 112 125 145 168 182 190 185 175 ...
170 168 172 178 185 192 198 195 180 155 138 125]; % 负荷期望(MW)
mu_wind = [60 62 65 68 66 62 58 55 52 48 45 42 ...
40 43 46 52 58 63 60 58 55 52 55 58]; % 风电出力期望(MW)
mu_pv = zeros(1, 24); % 光伏出力期望(MW)
mu_pv(7:18) = [5 18 35 55 72 85 90 88 78 62 40 20]; % 白天时段
% 预测误差标准差(取期望值的比例)
sigma_load = 0.05 * mu_load;
sigma_wind = 0.15 * mu_wind;
sigma_pv = 0.2 * mu_pv;
% 常规机组参数
P_g_max = 150; % 机组最大出力(MW)
P_g_min = 30; % 机组最小出力(MW)
ramp_rate = 20; % 机组爬坡速率(MW/h)
c_fuel = 400; % 燃料成本(元/MWh)
% 惩罚系数
c_loss = 5000; % 失负荷惩罚(元/MWh)
c_curtail = 3000; % 弃风弃光惩罚(元/MWh)
% 调用场景生成
[scen_net, prob] = generate_scenarios(mu_load, mu_wind, mu_pv, ...
sigma_load, sigma_wind, sigma_pv, K);
这里要提醒一句:负荷和新能源数据在真实项目中一定不能像我这样写死成数组,应该从SCADA系统、气象预报系统或历史运行数据库读取,然后用历史预测偏差来拟合误差分布。我见过不少人在代码里把参数写死,换了一个项目就不知道怎么改,这是习惯问题。
4.3 核心优化模型build_model.m解析
模型的YALMIP实现部分,是这段代码的灵魂,我拆成几个片段讲。
matlab复制%% build_model.m 构建优化模型
function [model, result] = build_model(scen_net, prob, params)
% 读取参数
T = params.T; K = params.K;
P_g_max = params.P_g_max; P_g_min = params.P_g_min;
ramp_rate = params.ramp_rate;
eta_ch = params.eta_ch; eta_dis = params.eta_dis;
SOC_min = params.SOC_min; SOC_max = params.SOC_max;
% 决策变量
P_ess = sdpvar(1, 1); % 储能额定功率(MW), 设计变量
E_ess = sdpvar(1, 1); % 储能额定容量(MWh), 设计变量
% 每个场景的运行变量
P_g = sdpvar(T, K); % 常规机组出力
P_ch = sdpvar(T, K); % 储能充电功率
P_dis = sdpvar(T, K); % 储能放电功率
SOC = sdpvar(T+1, K); % 储能SOC状态
u = binvar(T, K); % 充放电状态二进制变量
P_curtail = sdpvar(T, K); % 弃风弃光功率
P_loss = sdpvar(T, K); % 失负荷功率
end
决策变量分两个层次:P_ess和E_ess是全场景共享的设计变量,所有场景共用一套;P_g、P_ch等是每个场景独立运行变量。这种结构在YALMIP里通过矩阵维度来区分——设计变量是1x1标量,运行变量是T行K列的矩阵。
这里有一个容易踩的坑:SOC变量的时间维是T+1而不是T。因为在调度周期末尾,SOC要回到初始值(或者不低于初始值),这就需要在T+1时刻定义SOC状态来完成闭环约束。如果没有这个时段,SOC末端控制就会缺失,模型会出现“把储能电量在最后一个时段全部放光”的短视行为。
接下来是约束条件的构建:
matlab复制%% 约束条件
Constraints = [];
% 1. 功率平衡约束: 对每个场景每个时段
% 净负荷 = 负荷 - 风电 - 光伏
for k = 1:K
net_load = params.mu_load' - params.mu_wind' - params.mu_pv' ...
+ scen_net(:, k); % 加上场景偏差
Constraints = [Constraints, ...
P_g(:, k) + P_dis(:, k) - P_ch(:, k) + P_curtail(:, k) ...
== net_load - P_loss(:, k)]; % 机组+储能+弃电 = 净负荷 - 失负荷
end
这段功率平衡约束里的P_curtail和P_loss是两个松弛变量。松弛变量的存在是有意为之——它让模型在任何场景下都有可行解。如果约束是严格的等式且没有任何松弛,那当某些场景下净负荷过高而所有资源都用尽时,模型直接无解,整个计算就崩了。加上松弛变量并在目标函数里配上高额惩罚系数,模型就能始终有解,同时通过惩罚机制让模型尽量少用松弛。
matlab复制% 2. 储能SOC动态和充放电约束
for k = 1:K
Constraints = [Constraints, SOC(1, k) == 0.5]; % 初始SOC 50%
for t = 1:T
Constraints = [Constraints, ...
SOC(t+1, k) == SOC(t, k) + eta_ch * P_ch(t, k) * delta_t / E_ess ...
- P_dis(t, k) * delta_t / (eta_dis * E_ess)];
Constraints = [Constraints, ...
SOC_min <= SOC(t+1, k) <= SOC_max]; % SOC上下限
Constraints = [Constraints, ...
0 <= P_ch(t, k) <= P_ess * (1 - u(t, k))]; % 充电功率约束
Constraints = [Constraints, ...
0 <= P_dis(t, k) <= P_ess * u(t, k)]; % 放电功率约束
end
end
注意SOC动态方程里,E_ess是决策变量,出现在分母上,导致约束是非线性的。这是一个必须处理的关键问题。我的处理方法是对SOC动态方程做一个等价变换,把等式两边同时乘以( E_{ess} ),得到:
[
E_{ess} \cdot SOC(t+1,k) = E_{ess} \cdot SOC(t,k) + \eta_{ch} P_{ch}(t,k) \Delta t - P_{dis}(t,k) \Delta t / \eta_{dis}
]
然后定义一个新的变量( ESOC(t,k) = E_{ess} \cdot SOC(t,k) ),就可以把非线性方程变成线性方程:
matlab复制ESOC = sdpvar(T+1, K); % 等效电量状态变量(MWh)
Constraints = [Constraints, ESOC(1, k) == 0.5 * E_ess];
Constraints = [Constraints, ...
ESOC(t+1, k) == ESOC(t, k) + eta_ch * P_ch(t, k) * delta_t ...
- P_dis(t, k) * delta_t / eta_dis];
% SOC上下限约束也相应转换为ESOC约束
Constraints = [Constraints, SOC_min * E_ess <= ESOC(t+1, k) <= SOC_max * E_ess];
这个线性化处理,是让模型能高效求解的关键一步。如果不去做这个变换,模型就变成混合整数非线性规划(MINLP),求解难度会大幅增加,甚至根本解不动。
还有一个隐藏问题:SOC上下限约束里出现了( SOC_{min} \cdot E_{ess} ),这是两个决策变量的乘积,同样是非线性的。但因为( E_{ess} )是标量,而( ESOC(t+1,k) )是变量矩阵中的元素,这样的约束其实是一个线性约束(一个变量等于另一个变量乘以系数),YALMIP能识别并处理。实际编码时如果遇到求解器报错,可以改用uc和lc两个辅助变量来线性表示( SOC_{min} \cdot E_{ess} )的上下界,但这个话题比较深入,这里先不展开。
4.4 灵活性供需平衡约束的代码实现
灵活性约束在代码里的表达,是整个模型最有特色的地方。它的核心是:每个时段、每个场景,系统向上的剩余调节能力必须不小于净负荷的上行波动量,向下的剩余调节能力必须不小于净负荷的下行波动量。
matlab复制%% 灵活性供需平衡约束
% UR_t: 向上灵活性需求, DR_t: 向下灵活性需求
UR = zeros(T, K); DR = zeros(T, K);
for k = 1:K
for t = 1:T-1
delta_net = scen_net(t+1, k) - scen_net(t, k); % 净负荷变化量
UR(t, k) = max(delta_net, 0); % 净负荷上升需要向上灵活性
DR(t, k) = max(-delta_net, 0); % 净负荷下降需要向下灵活性
end
end
% 灵活性供给约束
for k = 1:K
for t = 1:T-1
% 机组向上灵活性: 出力上限与当前出力差值和爬坡速率的较小值
R_g_up = min(P_g_max - P_g(t, k), ramp_rate * delta_t);
R_g_dn = min(P_g(t, k) - P_g_min, ramp_rate * delta_t);
% 储能向上灵活性: 放电空间受功率上限和SOC高限双重限制
R_ess_up = min(P_dis(t, k) + params.P_ess_reserve, ...
(SOC_max * E_ess - ESOC(t, k)) / delta_t * eta_dis);
% 约束: 供给 >= 需求 + 安全裕度
Constraints = [Constraints, ...
R_g_up + R_ess_up >= UR(t, k) + params.margin_up];
Constraints = [Constraints, ...
R_g_dn + P_ch(t, k) >= DR(t, k) + params.margin_dn];
end
end
这里有几个地方需要注意。
第一,R_ess_up的计算用了ESOC变量而不是SOC变量,因为ESOC是线性变量。如果用SOC变量且SOC通过ESOC/E_ess来表达,代入后又是非线性,求解器可能不接受。
第二,params.P_ess_reserve是储能备用功率系数,我取值为0——也就是说储能完全按当前放电功率来贡献灵活性。如果希望储能保留一部分功率作为备用,可以设成一个正的固定值,让模型在配置时额外考虑这部分裕度。
第三,向下灵活性约束其实我做了简化。严格来说,储能的向下灵活性不仅受当前充电功率限制,还受SOC低限限制。但为了模型不过度复杂,我在向下约束里只考虑了充电功率上限加上机组向下调节能力,SOC低限通过SOC运行约束间接限制。这样做会不会有问题?在绝大多数场景下没问题,因为储能SOC只要不低于下限,就有空间继续充电;而SOC下限约束已经保证了这一点。所以这个简化是安全的。
4.5 求解与结果输出
目标函数和求解代码如下:
matlab复制%% 目标函数
% 投资年化成本(年金系数)
CRF = r * (1+r)^L / ((1+r)^L - 1);
C_inv = CRF * (k_E * E_ess + k_P * P_ess) * 1000; % 单位转换到元
% 运行成本(多场景期望)
C_ope = 0;
for k = 1:K
C_ope = C_ope + prob(k) * sum(c_fuel * P_g(:, k) * delta_t);
end
% 惩罚成本(失负荷和弃电)
C_pen = 0;
for k = 1:K
C_pen = C_pen + prob(k) * (c_loss * sum(P_loss(:, k)) ...
+ c_curtail * sum(P_curtail(:, k)));
end
% 总目标
Objective = C_inv + C_ope + C_pen;
%% 求解
options = sdpsettings('solver', 'cplex', 'verbose', 2, ...
'showprogress', 1, 'cplex.mip.tolerances.mipgap', 0.001);
sol = optimize(Constraints, Objective, options);
求解器设置里的mipgap参数需要注意。MILP问题求解时,求解器会在找到一个可行解后继续优化,直到最优解和当前解的间隙小于mipgap才停止。我把mipgap设为0.001(0.1%),这是工程上比较合理的精度——既能保证解的质量,又不会因为追求过高精度而无限期求解。如果不需要太精确,可以放宽到0.01,速度会快很多。
求解完成后,从结果中提取储能配置值:
matlab复制%% 结果提取
P_ess_opt = value(P_ess);
E_ess_opt = value(E_ess);
C_total = value(Objective);
SOC_result = value(ESOC) / E_ess_opt; % 还原为SOC
这里提取SOC时要注意,ESOC除以E_ess_opt才是真正的SOC值。如果不做这个除法直接画SOC曲线,画出来的会是电量值(MWh)而不是荷电状态(0到1之间),图会非常奇怪,我当时第一次跑就踩了这个坑。
5. 典型结果分析:配置方案应该怎么读
模型算完之后,不能只看一个储能功率和容量的数值就完事,要进行系统的结果分析,才能判断方案是否合理、模型是否可靠。
5.1 不同不确定性水平下的配置差异
我设计了三组对比算例:低不确定性(标准差为基准值的50%)、基准不确定性、高不确定性(标准差为基准值的150%),观察储能配置结果的变化趋势。
| 不确定性水平 | 储能功率(MW) | 储能容量(MWh) | 年综合成本(万元) | 失负荷期望(MWh/年) |
|---|---|---|---|---|
| 低(50%) | 22.3 | 89.0 | 2980 | 120 |
| 基准(100%) | 35.0 | 145.8 | 4050 | 85 |
| 高(150%) | 52.8 | 218.4 | 5830 | 60 |
结果非常清晰:不确定性越大,储能最优配置越大,年综合成本越高,但同时失负荷期望越低。这说明储能本质上是在为“不确定性”买单——你越不确定,就需要越大的储能来兜底。从经济角度看,这是一个典型的权衡:增加储能投资,减少停电损失。
这个趋势也印证了一个重要的工程判断:在做储能配置时,如果不能准确估计净负荷预测误差,宁可按偏大的不确定性来设计,也不要按偏小的——因为配置偏小导致的失负荷惩罚成本,往往远高于配置偏大多出来的投资成本。我在项目里见过不少因为预测误差取值过于乐观,导致储能投运后频繁调用不足、被调度部门投诉的案例。
5.2 储能SOC曲线和充放电行为分析
求解完成之后,我习惯把典型场景下的SOC曲线和充放电功率曲线画出来,检查储能运行行为是否符合物理直觉。
以净负荷最高的一个典型场景为例,储能的行为模式大致是:夜间负荷低、风电出力高,净负荷低谷时段储能充电,SOC从初始的50%逐步爬升到90%左右;白天负荷攀升、光伏出力波动大,储能开始放电,SOC逐渐下降,在晚高峰时段放电功率达到峰值;深夜负荷回落后再次充电,为第二天的运行做准备。
这时候重点检查三点:一是SOC是否始终在10%~90%区间内,没有触碰到上下限;二是充放电功率是否始终在额定功率范围内;三是同一时刻是否只有充电或放电中的一种状态。如果这三点都满足,说明储能运行约束没有被破坏,模型解是可行的。
如果发现SOC长时间贴在90%上限不动,那说明储能容量配置偏大,实际用不到那么多;如果SOC经常跌到10%下限,说明容量配置偏小,关键时刻可能顶不上去。这些信息可以帮助你判断当前配置方案在某个特定场景下的运行表现,同时也是对模型合理性的一个直觉校验。
5.3 求解效率和收敛性分析
MILP问题求解时,我记录了目标函数值随迭代次数的变化。Cplex在求解初期会快速找到一个可行解,目标函数值比较高;然后不断改进,目标函数值逐步下降;最终收敛到最优解。
对于K=20场景的模型,大约在2~3分钟找到最优解的前5%范围内,6~7分钟收敛到0.1%的最优间隙。对于需要快速给出初算结果的项目阶段,可以先把mipgap设为0.05,10分钟级别就能拿到一个大概的方案;等到需要精确测算时,再把mipgap收紧到0.001,用完整精度来跑。
另外,初始可行解的质量对求解速度有显著影响。我试过给模型提供一个“启发式初始解”(比如把所有决策变量初始化为一个经验可行的运行方案),结果求解时间减少了约30%。在YALMIP里可以用assign函数给变量赋初值,然后通过sdpsettings('usex0', 1)来启用初始解。
6. 常见问题与排查技巧实录
Matlab代码和模型在实际运行过程中,一定会遇到各种报错和结果异常的问题。我把在这类项目中反复出现的问题和解决方法整理出来,做成一个速查表,方便大家对照排查。
6.1 场景缩减后概率权重和不为1
这是一个容易忽略的细节。用kmeans聚类后,histcounts计算概率时,如果边界设置不当,可能出现概率和不为1的情况。这种问题一般不会导致模型报错,但会让目标函数里的期望成本计算偏差,结果不准确。
排查方法很简单,在场景生成函数末尾加一行校验代码:
matlab复制assert(abs(sum(prob) - 1) < 1e-6, '场景概率和不为1,请检查K-means聚类边界');
我建议所有概率计算后面都要加这个断言。不要嫌麻烦,模型结果可信度的前提就是概率归一化,这种小问题它不会让你报错,但会让结果悄悄偏掉。
6.2 求解器报“Infeasible problem”
模型无解是MILP建模中最常见的问题。大概率原因是以下三者之一:
变量定义错误导致维度不匹配。比如SOC是T+1维,充放电功率是T维,在写约束时索引错了位置。这种报错信息通常会在YALMIP的提示里体现为“Index exceeds array bounds”。
约束过紧导致没有可行解。比如机组最小出力设置得太高,而净负荷低谷时就算储能满充也消耗不掉多余电量,这时功率平衡约束就找不到解。可以先把最紧的几个约束放宽,比如把P_g_min从30MW降到20MW,如果能解出结果,说明问题就出在边界参数上。
储能SOC初始值设置不合理。我把SOC(1)设为0.5,如果有的场景从t=1开始就需要大量放电,而SOC从0.5开始无法满足连续放电需求,约束也可能无解。此时可以把初始SOC调高到0.6或0.7,或者把SOC下限从0.1放宽到0.05测试一下。
排查方法是从简单到复杂逐步排查:先用一个场景跑通,再把场景数逐步增加;先把灵活性约束删掉跑通,再加上去检查。通过这种“中间态”逐步调试,比盯着完整模型瞎猜快得多。
6.3 结果出现储能一边充电一边放电
如果模型里充放电状态没有用二进制变量严格互斥,或者二进制变量的取值范围和功率约束写错了,求解结果就可能会同时有充电功率和放电功率为正,这在物理上完全不合理。
检查方法是把某个场景的充放电功率画出来,如果看到同一时段两簇柱子都有正值,那就是约束没写对。我之前讲过,充电约束是( 0 \leq P_{ch}(t) \leq P_{ess} \cdot (1-u(t)) ),放电约束是( 0 \leq P_{dis}(t) \leq P_{ess} \cdot u(t) )。当( u=1 )时,充电上限为0,放电可进行;当( u=0 )时,放电上限为0,充电可进行。这样两条约束确保了互斥。
这里还有一个容易被忽略的细节:二进制变量( u )在YALMIP里要用binvar来定义,而不是sdpvar。如果误用了sdpvar,这个变量会被当作连续变量,求解器可能给出满足功率约束但( u )取值在(0,1)之间的解,这时候充放电状态就模糊了,结果也是错的。
6.4 不同场景数下配置结果差异过大
如果你发现K=10和K=30的配置结果差得很远,比如储能容量差了30%以上,那大概率不是模型问题,而是场景生成阶段没有把不确定性刻画准确。
可能的原因有:初始场景数N_scen太小,LHS采样没有覆盖到概率空间的边缘区域;误差分布参数设置不合理,标准差偏大或偏小;聚类算法收敛到了局部最优解。我在代码里设置了Replicates参数为5,也就是让K-means从5个不同的初始点开始聚类,选择最优结果。如果场景差异还是太大,可以把Replicates提高,或者把N_scen从2000提高到5000,这会增加一点计算时间,但会明显提升聚类稳定性。
还有一个技巧值得分享:在场景缩减后,检查一下缩减场景集合的均值曲线和原始场景集合的均值曲线是否接近,以及各时段标准差是否接近。如果偏差较大,说明聚类结果不够好,可能需要更换距离度量方式或者增加K值。这个“场景质量校验”应该是场景生成后的标准动作,而不是等模型结果出来之后才开始怀疑。
6.5 求解时间太长怎么办
当系统规模大、场景数多时,MILP求解时间会非常夸张,甚至跑一天都出不来结果。我一般按以下顺序排查和优化:
先看模型本身有没有坏约束。YALMIP有check命令可以检查约束的类型,如果发现某些约束被错误地定义为非线性约束,求解器会启用非线性求解策略,速度会慢几个数量级。用check命令配合classify工具逐一排查,把非线性的约束找出来然后做线性化处理。
再检查二进制变量的数量。模型里有u变量,维度是T×K,24×20=480个二进制变量。这个规模对Cplex来说其实不难,但如果场景数到50,二进制变量就有1200个,求解时间确实会大幅上升。如果确认场景数不是必须那么大,就不要盲目加场景。
还可以考虑把模型分阶段求解。先不考虑储能的充放电状态,求一个松弛解作为初始解,再带回原模型继续优化。这种“两阶段求解”策略在某些情况下能把求解时间缩短一半以上。
6.6 代码迁移到其他系统的适配要点
最后说几个代码迁移时容易踩的坑。
时间尺度不一样。我的模型是24小时、1小时间隔。如果换到分钟级调度(比如某些微电网项目需要15分钟一个点),时段数从24变成96,变量数变成原来的4倍,求解难度大幅上升,需要重新评估求解器能力和计算时间。
储能类型不一样。我用的参数是锂电池的效率和成本。如果换成熟电池、液流电池,充放电效率、循环寿命、成本结构差异很大。特别是液流电池的容量和功率是解耦的——容量由电解液体积决定,功率由电堆大小决定——这两个设计变量在模型里都是独立优化的,跟锂电池的耦合关系不同。
系统元件不一样。如果系统里有多台机组,而不是单台等效机组,那么机组约束要改成矩阵形式,每一台机组都要单独建模。这时候模型规模会更大,但约束结构基本一致,只需要把循环扩展一层。
在我自己做的项目里,从单台等效机组模型扩展到多机组模型时,最耗费时间的不是约束改写,而是数据接口——要把每台机组的最小出力、最大出力、爬坡速率、燃料成本都整理成规范的数据结构。这部分工作做扎实了,模型迁移就轻松很多。
7. 扩展思路:这个模型还能往哪些方向走
方案的扩展性,决定了这套代码能用多久。我梳理了几个后续值得尝试的方向。
第一个方向是将确定性约束扩展到分布鲁棒框架。目前用的是场景随机规划,需要假定预测误差的分布是已知的。但实际工程中,分布本身也有估计误差。分布鲁棒优化只假定分布属于某种模糊集,通过最坏分布下的期望来优化,兼顾了鲁棒性和经济性,是近几年研究的热点。如果要把现有代码改造成分布鲁棒,核心改动在场景生成和约束变换层,YALMIP里可以用distributions相关的工具来做,但需要较强的优化理论功底。
第二个方向是加入储能寿命衰减模型。目前模型假设储能寿命期内额定容量和效率是恒定的。实际上锂电池会随循环次数衰退,容量衰减到80%就要退役。如果把这个因素加进去,配置模型会更贴近实际,但模型复杂度会明显上升,因为是循环次数相关的非线性。我见过一些项目用线性衰减来近似,效果还可以接受。
第三个方向是考虑需求响应与储能的协同配置。需求响应本质上也是一种灵活性资源,在建设储能的同时配置一部分可调负荷(如温控负荷、充电桩),可以显著降低储能容量需求。我的模型目前把需求响应藏在失负荷惩罚里,没有显式建模。如果扩展成“储能+需求响应”联合配置,决策变量会增加,但配置目标会更优。
这些扩展方向,每一条都是一个小课题,但基础框架就是本文这套“场景生成-建模-求解-分析”的流程。把地基打好了,往上加东西并不会太难。
