前几天帮一个做电力市场方向的朋友调代码,发现他写的储能在现货市场里的优化模型有个特别典型的毛病:SOC曲线排得整整齐齐,低充高放,视觉效果拉满,可一部署到联合出清场景中就崩——储能在某个时段要同时提供调频备用和正常充放电,结果功率容量不够了,模型直接给出不可行解。独立储能的现货电能量与调频辅助服务市场出清协调机制(Matlab代码实现),解决的就是这个真实存在的容量冲突问题。这篇文章我会从问题背景、数学模型、Matlab代码实现、算例对比和实战避坑几个维度,把这套代码背后的思路完整捋一遍。适合电力系统方向的研究生、做市场出清仿真的一线工程师,以及刚接触储能建模但被电能量市场和调频市场联动关系绕晕的初学者。
1. 为什么储能在电力市场中必须“一条腿走两条路”
1.1 电能量市场里的“套利天花板”
先算一笔账。假如你有一座50MW/100MWh的独立储能电站,只参与现货电能量市场,玩法很简单:负荷低谷电价低的时候充电,负荷高峰电价高的时候放电,赚的是峰谷价差。如果一天只做一个完整循环,假设峰谷价差是0.4元/kWh,那理论上一天的毛收入是:
100MWh × 0.4元/kWh = 4万元
看起来还行,但这里有个前提:你得保证每天都能找到一个完整的低充高放窗口,而且电池不能过度充放。实际运行中,充电侧和放电侧都有损耗,电化学储能的充放电效率通常在90%左右,相当于一次循环先打个八折。再加上电池容量衰减、运维成本、租赁费用这些隐性支出,纯电能量套利的收益其实非常薄。更关键的是,很多现货市场的峰谷价差远远到不了0.4元/kWh,可能只有两三毛,这时候储能单靠套利连成本都收不回来。
所以才有那句行业老话:储能如果只做电能量套利,大概率是算不过账的。这也是为什么几乎所有大型独立储能项目都在争取参与辅助服务市场,尤其是调频市场。
1.2 调频市场的高回报与高门槛
调频辅助服务的收益逻辑和电能量市场完全不同。调频市场给储能两类补偿:一类是容量补偿,你承诺预留出多少MW的功率随时听从系统调度,不管你实际动没动,都按预留容量给你钱;另一类是里程补偿,你实际参与了多少次调节、调节了多少MW,按调节里程额外算钱。
储能在调频市场有天然优势——响应速度快。传统火电机组从AGC指令下达到实际出力变化,可能需要几十秒到几分钟,而储能是毫秒级响应。因此在同样的调频容量需求下,储能中标的概率和调节性能都远高于火电。
但高回报对应高门槛。一旦它中标了调频容量,这部分功率就被“锁定”了,不能同时又拿去充电或放电。比如一个50MW的储能,中标了20MW调频容量,那么在同一个时段里,它最多只能再充或放30MW。这个物理约束就是电能量市场和调频市场之间的耦合,也是所有协调机制的起点。
1.3 独立储能参与协调的市场定位
这里要澄清一个概念:独立储能和传统的“新能源配储”不一样,它是独立的市场主体,不依附于某个风电场或光伏电站,可以自由决定是充电、放电还是提供调频。它的身份更灵活,给市场出清模型带来的变量和约束也更复杂。
本文说的“市场出清协调机制”,是站在系统运行机构的角度,把电能量出清和调频出清放到同一个优化模型里来解。储能既报电能量价格又报调频容量价格,系统一次性决策出每个时段的中标电能量出力和中标调频容量。这比“两个市场各自独立出清、事后靠人工协调”的方式更高效,也能从根上避免前面说的功率容量冲突。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 现货电能量与调频市场联合出清的数学模型
2.1 决策变量:储能到底要“算”什么
先明确这个模型里有哪些决策变量。为了说清楚,我按一天24个时段、共ng台火电机组加1台储能来设计。
| 变量 | 含义 | 数量 |
|---|---|---|
| P_gen(i,t) | 火电机组i在t时段的电能量出力 | ng × T |
| R_gen(i,t) | 火电机组i在t时段的调频容量 | ng × T |
| P_dch(t) | 储能在t时段的放电功率 | T |
| P_ch(t) | 储能在t时段的充电功率 | T |
| R_sto(t) | 储能在t时段的调频容量 | T |
| SOC(t) | 储能在t时段的荷电状态 | T+1 |
| u_sto(t) | 储能充放电状态标志,0表示充电,1表示放电 | T |
这里最容易被忽略的是u_sto(t)这个二值变量。如果没有它,模型可能在一个时段里同时让P_ch和P_dch都大于零,也就是电池一边充电一边放电,这样既不物理,还会造成虚假的收益空间。引入这个0/1变量后,充电和放电不会同时发生。
2.2 目标函数:两层成本怎么同台竞价
联合出清的目标函数是系统总成本最小化。电能量市场有报价成本,调频市场也有容量成本和里程成本,两边必须放到同一个式子里来比价。这里的关键技巧是“里程折算”:调频里程不是直接拿一个变量参与优化,而是用里程系数和调频容量相乘得到。
目标函数可以写成:
min Σ_t
其中α是里程系数,表示每预留1MW调频容量,平均会产生多少MWh的调频里程。这个系数通常来自历史运行数据的统计,属于市场规则层面的参数。
为什么要“同台竞价”?因为如果电能量和调频分开出清,储能很可能在电能量市场中标了30MW充电,又在调频市场中标了30MW调频,加起来超过额定功率,结果就是两个市场的出清结果互相打架。联合出清把两种成本统一放进目标函数,把功率耦合关系写进约束,实际上是在同一时刻给储能的功率做“竞争性分配”。
2.3 约束条件:功率、SOC与调频响应速率的耦合关系
约束条件分成几个层次。第一层是电力系统的公共约束,也就是每个时段的功率平衡:
Σ_i P_gen(i,t) + P_dch(t) - P_ch(t) = Load(t)
第二层是储能自身的运行约束。最核心的是功率分配约束,也就是把额定功率在“充电/放电/调频”三者之间做分配:
R_sto(t) + P_ch(t) ≤ P_sto_max
R_sto(t) + P_dch(t) ≤ P_sto_max
这两条约束用大白话解释就是:调频容量和充电功率加起来不能超过额定功率,调频容量和放电功率加起来也不能超过额定功率。这是整个协调机制的核心所在。很多初版模型漏掉的就是这两行,导致结果出现容量超限。
第三层是SOC递推方程:
SOC(t+1) = SOC(t) + η_c·P_ch(t)·Δt - P_dch(t)/η_d·Δt
以及SOC上下限约束、初值末值约束:
SOC_min ≤ SOC(t) ≤ SOC_max
SOC(1) = SOC_init,SOC(T+1) = SOC_init
第四层是火电机组的爬坡约束和调频容量约束。火电的调频容量不能超过其可用裕度,也就是:
R_gen(i,t) ≤ P_gen_max(i) - P_gen(i,t)
这个约束意味着火电机组不能又把功率发满、又承诺大量调频,物理上讲不通。储能同样有这个限制,只不过储能的爬坡能力强得多,一般不需要单独写爬坡约束,但功率裕度约束是必须的。
3. 从模型到可运行程序:Matlab实现与YALMIP建模
3.1 整体项目文件结构与数据准备
在Matlab里实现这类联合出清模型,我建议不要把所有代码塞进一个脚本里,而是按功能拆成几个文件。我自己常用的项目结构是这样的:
text复制main_clearance.m % 主程序:读数据、建模、求解、输出
data_case6.m % 算例数据:负荷、机组参数、储能参数
model_clearance.m % 模型构建函数:返回约束和目标函数
plot_results.m % 结果可视化
results/ % 存放结果和图片
这样拆的好处是调试方便。改储能参数不用动模型,改模型结构不用动数据,换算例只改data_case6.m里的数组就行。
数据准备阶段最核心的是定义负荷曲线。以一个24时段为例:
matlab复制%% 负荷数据(单位:MW)
Load = [60 58 56 55 57 62 68 75 82 88 90 89 87 83 80 78 82 88 92 95 93 88 79 68];
T = length(Load); % 时段数,这里为24
火电机组参数、储能参数也要在数据文件里统一定义。这里强调一下,所有数据必须统一单位。如果能量报价是元/MWh,功率就用MW,时间间隔用小时,SOC是百分比,这样递推方程里的能量单位才能对齐。单位混用是这类代码最隐蔽的一类错误。
3.2 YALMIP建模的核心代码解析
建模采用YALMIP工具包,它能把优化模型用Matlab矩阵方式表达,然后自动调用Gurobi、Cplex等求解器。先定义决策变量:
matlab复制%% 决策变量定义
P_gen = sdpvar(ng, T, 'full'); % 火电各时段电能量出力
R_gen = sdpvar(ng, T, 'full'); % 火电各时段调频容量
P_dch = sdpvar(1, T, 'full'); % 储能各时段放电功率
P_ch = sdpvar(1, T, 'full'); % 储能各时段充电功率
R_sto = sdpvar(1, T, 'full'); % 储能各时段调频容量
SOC = sdpvar(1, T+1, 'full'); % 储能荷电状态,含初值和末值
u_sto = binvar(1, T, 'full'); % 储能充放电状态:1放电,0充电
接下来是约束条件。用一个for循环把所有时段的约束叠加上去:
matlab复制Cons = [];
%% 功率平衡约束(保存句柄,后面用来取节点电价)
Cons_balance = [];
for t = 1:T
Cons_balance = [Cons_balance, sum(P_gen(:,t)) + P_dch(t) - P_ch(t) == Load(t)];
end
Cons = [Cons, Cons_balance];
%% 储能运行约束
for t = 1:T
% SOC递推
Cons = [Cons, SOC(t+1) == SOC(t) + eta_c*P_ch(t)*dT - P_dch(t)/eta_d*dT];
% 充放电互斥
Cons = [Cons, 0 <= P_ch(t) <= P_sto_max * (1 - u_sto(t))];
Cons = [Cons, 0 <= P_dch(t) <= P_sto_max * u_sto(t)];
% 调频容量与电能量功率的耦合约束
Cons = [Cons, R_sto(t) + P_ch(t) <= P_sto_max];
Cons = [Cons, R_sto(t) + P_dch(t) <= P_sto_max];
% SOC上下限
Cons = [Cons, SOC_min <= SOC(t+1) <= SOC_max];
end
%% 储能SOC初值与末值
Cons = [Cons, SOC(1) == SOC_init, SOC(T+1) == SOC_init];
注意充电功率约束里的 (1-u_sto(t)) 和放电功率约束里的 u_sto(t),两个乘在一起就保证了任何时刻只有一个方向。这在混合整数线性规划里是标准写法。
目标函数按第二节的公式写:
matlab复制%% 目标函数:系统总成本最小化
% 火电部分:电能量成本 + 调频容量成本 + 调频里程成本
Obj_fire = sum(sum( C_gen_P .* P_gen + C_gen_R .* R_gen + C_gen_M .* alpha .* R_gen ));
% 储能部分:调频容量成本 + 调频里程成本
Obj_sto = sum( C_sto_R .* R_sto + C_sto_M .* alpha_sto .* R_sto );
Obj = Obj_fire + Obj_sto;
求解时,我一般用Gurobi解混合整数线性规划:
matlab复制%% 求解
ops = sdpsettings('solver', 'gurobi', 'verbose', 1, 'mipgap', 0.0001);
sol = optimize(Cons, Obj, ops);
if sol.problem ~= 0
error('模型求解失败:%s', sol.info);
end
如果没有Gurobi,也可以用Cplex或SCIP。如果只是快速验证模型逻辑,把u_sto这个二值变量去掉做线性松弛求解,速度和成功率都会高很多,但严格意义上不能当最终结果。
3.3 求解结果的重构与出清结果输出
求解完之后,要用value()函数把YALMIP变量转换成数值,才能做分析和绘图:
matlab复制%% 结果提取
P_gen_opt = value(P_gen);
P_dch_opt = value(P_dch);
P_ch_opt = value(P_ch);
R_sto_opt = value(R_sto);
SOC_opt = value(SOC);
电能量价格可以通过功率平衡约束的对偶变量来取。在YALMIP中,如果约束句柄保存为Cons_balance,那么:
matlab复制lambda_e = dual(Cons_balance); % 各时段的节点电能量边际价格
调频容量价格对应的则是调频容量平衡约束的对偶变量,需要你在建模时也把那一组约束单独保存句柄。这个价格信息对储能运营商做收益评估特别有用,因为储能在一段时间内的电能量收益等于各时段的放电量乘以电价再减去充电量乘以电价。
最后把结果保存到results目录下,并调用绘图脚本:
matlab复制save('results/clearance_result.mat', 'P_gen_opt', 'P_dch_opt', 'P_ch_opt', 'R_sto_opt', 'SOC_opt', 'lambda_e');
plot_results;
4. 算例验证:三种参与模式下储能收益的对比
4.1 测试系统与参数设置
为了直观展示协调机制的作用,我设计了一个简化系统:两台火电机组加一台独立储能,负荷用前面那个24小时序列。火电机组参数和储能参数如下表。
| 机组 | 额定功率(MW) | 电能量报价(元/MWh) | 调频容量报价(元/MW) | 调频里程报价(元/MW) | 里程系数α |
|---|---|---|---|---|---|
| G1 | 100 | 300 | 50 | 15 | 8 |
| G2 | 80 | 450 | 80 | 25 | 8 |
| ESS | 50 | -(按充放行为) | 30 | 10 | 6 |
储能容量100MWh,充放电效率都是0.9,SOC上下限0.1和0.9,初值0.5,要求日末SOC也回到0.5。这里把储能SOC末值强制回到初值,是为了避免模型把电池电量在一天内全部放空,产生不真实的“收益”。
4.2 纯电能量套利与联合出清的结果差异
我做了两种场景的对比:
- 场景A:储能只参与电能量市场,不申报调频容量,也就是R_sto强制等于0。
- 场景B:储能同时参与电能量和调频市场,按联合出清模型求解。
用同样的负荷和报价数据跑出来的典型结果如下(具体数值会随报价和负荷变化,但趋势稳定):
| 指标 | 场景A(纯电能量) | 场景B(联合出清) |
|---|---|---|
| 储能日充电量(MWh) | 87.2 | 61.5 |
| 储能日放电量(MWh) | 78.5 | 55.3 |
| 平均中标调频容量(MW) | 0 | 18.6 |
| 电能量净收益(元) | 16200 | 9400 |
| 调频容量+里程收益(元) | 0 | 21400 |
| 储能总收益(元) | 16200 | 30800 |
场景A里储能把功率全部用在电能量套利上,一天充放得多,看起来“忙忙碌碌”,但总收益只有1.6万。场景B里储能减少了充电量和放电量,把接近20MW的功率腾出来给调频市场,电能量收益确实降了6800元,但调频市场多赚了21400元,总收益几乎是纯电能量套利的两倍。
这个结果并不反直觉,它反映的正是独立储能的本质特征:储能不是一块单纯的“可移动负荷”,它的价值在于灵活性。同一个100MWh电池,用在不同市场上,边际价值完全不同。联合出清机制做的就是在这些价值之间做最优权衡。
4.3 SOC轨迹与调频备用响应曲线解读
从SOC轨迹看,两种场景的差异非常明显。场景A的SOC曲线是典型的“两段式”:凌晨低谷一路充到上限附近,傍晚高峰一路放到下限附近,中间几乎没有犹豫。场景B的SOC曲线则柔和得多,它不会在谷段把电充满,因为如果SOC太高,到某些时段它需要预留调频容量时,功率容量和SOC裕度就都会紧张;同理,它也不会在高峰时段把电彻底放到底,而是保留一段电量作为调频动作的安全裕度。
更值得关注的是功率分配约束的活跃情况。我在调试模型时,习惯在结果里补充一段诊断代码,检查每个时段R_sto + P_dch是否等于额定功率。凡是等于额定功率的时段,说明储能在那个时段正处于“功率容量卡边”状态,调频和放电在争抢空间;凡是没到边界的时段,说明储能还有额外功率空间,要么是调频容量收益不够吸引人,要么是SOC约束先到了边界。
这种诊断对理解协调机制特别有用。你可以清晰看到,系统在哪些时段愿意让储能提供调频,在哪些时段宁愿让它老实充放电。这就是市场价格的引导作用在优化结果里的体现。
5. 这套代码从“跑通”到“跑准”必须避开的六个细节
5.1 时间分辨率与滚动时域的选择
很多初学者一上来就按96个时段跑15分钟分辨率,结果遇到MILP求解慢、调试困难。我建议先用24个时段把模型逻辑调通,确认约束没漏、目标函数方向没错,再改成96时段或更细的分辨率。现货市场实际出清节奏往往更细,但那是生产级系统的事,研究原型重点是把机制讲清楚。
如果后续要做实时调度或日内滚动,可以把模型改造成滚动时域控制,每次优化一个窗口,只执行第一个时段的结果,然后到下一个时段重新优化。Matlab实现上就是套一层for循环,每次更新负荷预测和SOC初值。
5.2 初始SOC与末端SOC的边界处理
这是储能模型里最容易被“优化算法占便宜”的地方。如果不加SOC(T+1) == SOC_init这个约束,模型会怎么做?它会让储能把电量在一天内全部放光,因为放光的电量全部算作收益,而电池“明天怎么充回来”不在优化范围内。这样算出来的收益虚高,完全不可持续。
解决方式有两种:一种是硬约束,要求SOC(T+1)等于SOC_init,适合做典型日分析;另一种是软约束,在目标函数里加一个SOC(T+1)和SOC_init偏差的惩罚项,适合做滚动调度。我自己的习惯是先用硬约束,因为模型干净、好解释,后面需要滚动再改软约束。
5.3 调频里程价格与里程系数的折算
如果只把调频容量价格放进目标函数,相当于默认调频里程是免费赠送的,这会低估调频成本,导致储能中标调频容量过高。反过来,如果里程系数取得过大,又会把调频收益高估。这个α不是一个拍脑袋的数,它应该基于实际市场的历史数据统计:某时段全系统调频总里程除以该时段调频总容量,得到一个有物理意义的比值。
我见过不少项目代码里,调频里程这项直接没写,最后结果被审稿人或领导一问就露馅。务必记得:调频容量和调频里程是两个不同的收益来源,必须分开建模,至少在代码注释里把α的取值依据写清楚。
5.4 求解器选择与热启动策略
对于含二值变量的MILP模型,当机组数和时段数上来以后,求解时间会明显增加。比如ng=10、T=96时,二值变量数量在千量级,Gurobi默认参数通常几十秒内能解,但如果模型结构不好,也可能卡上几分钟。
几个实用策略。第一,设置合理的mipgap,比如0.0001,既能保证精度又不需要等绝对最优。第二,先用连续松弛版模型跑一遍,把得到的整数变量结果作为MILP的初始可行解写进去,可以大幅缩短搜索时间。在YALMIP里,用assign和sdpsettings('usex0',1)可以实现这一点。第三,如果储能数量多、启停状态复杂,可以考虑把储能按聚合体建模,减少整数变量规模。
5.5 结果可视化与报告输出
plot_results.m这个脚本不要糊弄。我一般画四张图:
- 第一张:各机组出力堆叠面积图,横轴是24时段,纵轴是功率,直观看出系统在每个时段的发电组合。
- 第二张:储能的充电功率、放电功率、调频容量三条曲线画在一起,用bar或stairs,一眼看出功率容量分配的博弈关系。
- 第三张:SOC曲线,重点看是否在上下限内、初末值是否一致、谷段和峰段的行为是否符合直觉。
- 第四张:节点电能量的边际价格曲线,对应功率平衡约束的对偶变量。
图片直接保存成png,再加一张结果汇总表导出到Excel或CSV,方便写报告时引用。
5.6 灵敏度分析怎么做
模型跑通以后,不要急着下结论说“协调机制提升了储能收益”。先做一轮灵敏度分析,把主要参数扫一遍,确认结论不是某个参数特例下的偶然结果。
最简单的方式是在主程序里套for循环。比如把储能额定容量从50MW扫到100MW,记录每种容量下的储能总收益、系统总成本、平均中标调频容量,最后画一条曲线。你会发现,随着容量增大,储能的总收益增速会放缓,原因是容量大了以后,储能会在更多时段边际效益递减;而调频容量报价升高到某个阈值时,储能中标调频容量会出现断崖式下降,这就是市场选择机制在起作用。
灵敏度分析本质上是在问一个问题:你的机制在参数变化范围内,结论是否稳健?如果换一组负荷曲线,结论就反转了,那说明模型或机制设计有问题,正好提前暴露出来。
最后聊点个人体会。这套代码我前前后后改了很多版,最容易让人心态崩的不是模型本身,而是数据口径。同一个储能,有的数据表写额定功率50MW,有的写可用容量100MWh,单位一混,约束全乱。建议从一开始就在data_case里把单位全部统一,并给每个变量写一行注释。调模型时如果结果反直觉,先不要怀疑求解器,先去看SOC和R_sto是不是同时非零,大概率是某个耦合约束漏写了一行。把这些基础工作做扎实,后面无论是换算例、加约束还是做扩展,都会顺畅很多。
