独立储能在电能量和调频两个市场里怎么同时出清,我这次用Matlab把整套协调机制跑通了,正好把思路和实现细节整理出来。
一直有朋友问,独立储能在电力市场里到底应该怎么建模、怎么参与出清。电能量市场和调频辅助服务市场是两种完全不同的交易品种,储能机组同时参与这两个市场时,容量怎么分配、SOC怎么约束、机会成本怎么算,每一步都有讲究。我这次仿真的是一个典型的协调出清机制:把现货电能量市场与调频辅助服务市场放在同一个优化框架里联立求解,而不是传统意义上先出清能量市场、再单独出清调频市场。整个程序用Matlab编写,调用线性规划求解器,跑出来的结果能清楚看到储能机组在各时段的充放电计划、调频容量预留、SOC轨迹以及两个市场的出清价格。
这套代码的适用场景很明确:做电力市场方向研究的学生、从事现货市场系统建设的工程师、以及关注储能商业模式的投资分析人员,都能拿它做机制验证或算例分析。我今天把整体设计思路、数学建模过程、代码实现细节和调试中踩过的坑一次性讲清楚,争取让拿到代码的人不只是会运行,还能根据自己需要改机制、换参数、扩展功能。
1. 项目场景与机制设计思路
1.1 独立储能参与市场的现状与困境
先说清楚独立储能在当前电力市场里到底面临什么问题。储能和传统火电最大的不同在于能量双性,它既能充电也能放电,并且存进去的能量有时间属性,这一时刻充了多少电,下一时刻才能放出来。很多人在仿真里把储能当成一个"可正可负的出力机组",这是不对的,至少在市场出清层面,忽略SOC时序耦合会造成非常严重的容量误配。
独立储能要同时参与现货电能量市场和调频市场,核心矛盾在于调频服务要求储能提前预留一部分容量,这部分容量不能再参与电能量的充放电。如果两个市场分别独立出清,储能会倾向于把全部容量投到收益更高的市场里,导致另一个市场出清结果失真。举个极端例子:某个时段电能量价格高,储能如果把全部容量用于放电,调频市场就没有储能资源可用;反过来,如果调频里程价格高,储能把所有容量都上报为调频备用,电能量市场就失去了这个低价电源。两个市场的出清价格都会因此被扭曲。
解决这个问题的标准做法是联合出清,也有人叫协调出清,本质上是把两个市场统一放到一个优化问题里,让储能容量在两个市场间的分配由一个全局目标函数决定,而不是人为先定好。这一思路在国内现货市场试点中已经有实际落地,最早是在调频市场引入储能之后,调度机构发现独立储能申报电能量曲线和调频容量之间经常冲突,才逐步把两个品种放在一起考虑。我这个仿真代码模拟的正是这种联合出清机制。
1.2 协调机制的核心逻辑:分时耦合与容量竞争
联合出清机制最核心的逻辑是两条:分时耦合和容量竞争。
分时耦合很容易理解,储能的SOC是跨时段递推的。t时段的充放电决策会影响t+1、t+2甚至更晚时段的可用能量。出清模型必须建立完整的SOC转移约束,通常写成:
SOC(t+1) = SOC(t) + η_c · P_c(t)·Δt - P_d(t)·Δt / η_d
其中η_c和η_d是充放电效率,P_c和P_d是充放电功率。这个方程看起来简单,但在市场出清模型中它带来的是跨时段耦合约束,无论用什么样的求解器,这都会增加问题复杂度。
容量竞争指的是在一个时段内,储能的总可用功率要在电能量出力和调频备用之间分配。如果储能的最大充放电功率是P_max,那么:
|P_energy| + P_reserve ≤ P_max
这里的P_reserve就是储能承诺参与调频而预留的功率容量。注意在联合出清里,P_energy和P_reserve都是决策变量,它们之间的最优分配由两个市场的边际收益共同决定。储能参与电能量市场的收益来自电能量价格差低充高放,参与调频市场的收益来自调频容量补偿和里程补偿。如果调频市场价格高,模型就会自动把更多的容量分给调频;如果电能量市场峰谷价差大,模型就会倾向于多充电放电赚钱。
我在建模时把这两个逻辑都做成了线性约束,这样整个问题就保持为线性规划。后续想扩展成混合整数规划也很方便,只需要给充放电状态加二进制变量。
1.3 为什么选择Matlab实现联合出清模型
关于实现工具,我做过对比。Python的Pyomo和GAMS都适合做这类问题,GAMS建模能力强,但license费用高,学生和中小团队很难承担。Pyomo开源免费,但要拼装solver环境,对电力系统背景的工程师来说,Yalmip+Matlab反而是最顺手的一套组合。
Matlab做电力市场出清仿真有一个显著优势:Yalmip工具箱把建模语言封装得非常好,它支持直接用符号表达式写约束条件,底层求解器可以随时切换,比如从默认的linprog切换到Gurobi或者Cplex。这意味着你在建模阶段不需要考虑求解器语法,只需要关心数学模型本身,写出来的代码和论文里的数学公式几乎是一一对应的,排查问题非常方便。
另外,Matlab的绘图能力在处理出清结果可视化时很占优势。市场出清代码跑完之后,通常要输出机组出力曲线、储能SOC曲线、节点电价或统一出清价曲线、调频容量分配饼图等,这些图表用Matlab自带的plot、stairs、area、bar函数几行代码就能完成,效果也够专业。对于需要频繁调参数跑仿真的场景,Matlab的脚本化运行方式也比GUI操作更高效。
这套代码的调试环境是Matlab R2022a及以上版本,需要安装Yalmip工具箱,求解器可以选择Gurobi 9.5+或Cplex 12.10+,如果没有商业求解器,直接用Matlab自带的linprog也能跑通小规模算例,只是求解速度会慢一些。我后面会详细说明各个求解器的配置差异。
2. 市场出清模型的构建与求解
2.1 目标函数:两个市场价格信号如何统一
联合出清的目标函数设计是整个模型的核心。传统分步出清的顺序是:先出清电能量市场得到机组组合和出力计划,再在已知电能量出力的基础上出清调频市场。但这种方式对于储能这种具备能量时间转移属性的资源非常不友好,因为在电能量市场出清时根本没有考虑调频预留容量,储能可能被安排满发,到调频市场里就完全丧失了响应能力。
联合出清代价是目标函数里同时包含两个市场的报价成本。电能量市场的成本项是所有出售电能量的机组(包括储能放电)的报价乘以出力,再加上购买电能量(储能充电)的报价乘以充电功率;调频市场的成本项是调频容量价格乘以预留容量,加上调频里程价格乘以预期里程。
具体到我的仿真里,目标函数写成:
min Σ_t [ Σ_g C_g(t)·P_g(t) + C_buy(t)·P_ch(t) - C_sell(t)·P_dis(t) + Σ_f R_f(t)·Q_f(t) + M_f(t)·Mile_f(t) ]
其中C_g是常规机组报价,P_g是出力;C_buy是储能充电报价,P_ch是充电功率;C_sell是储能放电报价,P_dis是放电功率;R_f是调频容量价格,Q_f是预留容量;M_f是调频里程价格,Mile_f是预期调频里程。
目标函数前两项是电能量市场总购电成本,第三项是储能放电视为机组出力的收益抵扣(在最小化成本框架里,收益以负成本形式出现),最后两项是调频备用采购成本。通过统一最小化,模型自动在储能放电收益和调频机会成本之间做权衡。这个建模方式在数学上等价于最大化社会福利,只要需求侧弹性忽略不计或者按固定负荷处理。
2.2 储能约束集:充放电逻辑与SOC递推
储能约束是协调出清模型里最需要小心处理的部分。常见的问题是把充放电功率直接写成-1到1倍数的一个连续变量,但这样会在某些时段同时出现充电和放电的非零值,物理上不可能,经济上也可能占便宜。为了避免这个情况,我采用了两种方案。
第一种是哐分变量方案,也是我代码里默认的方式:充电功率P_ch和放电功率P_dis分别建模,两者都有非负下界和功率上界:
0 ≤ P_ch(t) ≤ u_ch(t)·P_max 0 ≤ P_dis(t) ≤ u_dis(t)·P_max u_ch(t) + u_dis(t) ≤ 1
这里u_ch和u_dis是0/1变量。使用混合整数线性规划MILP可以严格防止同时充放电。不过MILP求解在小规模算例上没问题,扩展到大系统时计算负担会显著增加。
第二种方案是去掉0/1变量,直接加一条互补约束P_ch(t)·P_dis(t) = 0。但这个约束是非线性的,会让问题变成非凸优化,求解不稳定。实际工程中更常用的做法是直接允许微小偏差,或者利用市场规则防止同时充放电,比如储能充电按负荷处理、放电按机组处理,在费用结算时设置不合理套利惩罚。
我的代码里默认采用MILP方案,因为它的物理意义清晰、求解结果可解释性强。对于线性规划偏好的用户,代码里也留了开关,可以切换成线性规划模型忽略同时充放电约束,速度快很多但个别极端价格场景下会出现充电放电同时为正的结果。
SOC递推约束前面已经写过,还要注意加上SOC的上下限约束:
SOC_min ≤ SOC(t) ≤ SOC_max
同时为了体现储能寿命管理,我设置了SOC_min不低于0.1,SOC_max不高于0.9,避免了深度充放电对电池寿命的影响。调频服务对储能SOC的影响通过调频备用约束体现:预留调频容量意味着充电上限和放电上限都要相应收缩。
2.3 调频市场约束与里程电价联动关系
调频辅助服务市场的建模远比电能量市场复杂,因为调频服务有两个维度的回报:容量价格和里程价格。容量价格是为机组预留可用响应能力付费,相当于"占位费";里程价格是为机组实际完成的调节里程付费,相当于"工作量费"。
在我的模型里,储能参与调频时预留了功率容量Q_f,这部分容量需要同时约束充电方向和放电方向的可调节裕度。具体约束是:
Q_f(t) ≤ min( P_max - P_dis(t) + P_ch(t), P_max - P_ch(t) + P_dis(t) )
这个约束的含义是:在给定当前充放电功率的情况下,储能向上调节的容量(减少充电或增加放电)和向下调节的容量(增加充电或减少放电)都必须至少覆盖承诺的Q_f(t)。很多初学者会漏掉这个约束,结果模型给出的调频容量远超储能实际可调能力,出清结果在物理上不可行。
里程和容量的关系在实际市场中不是线性的。调频里程通常等于调频容量乘以一个与系统频率偏差概率分布有关的系数λ,在我的模型里简化处理为:
Mile_f(t) = λ · Q_f(t)
λ一般取1.5到3之间的常数,代表单位容量在单位时间内可能产生的调节里程期望值。不同系统的λ差异很大,风电光伏占比高的系统频率扰动大,λ取偏高值;火电为主的系统λ相对较低。在代码里我提供了λ的参数配置,用户可以在config文件中直接修改。
另一个容易被忽视的约束是调频容量需求约束,系统每个时段对调频资源的总需求是固定的:
Σ_f Q_f(t) + Q_storage(t) = D_reg(t)
D_reg是系统调频容量总需求,一般按预测负荷的一定比例设定,华北地区现货规则里调频容量需求通常是最大负荷的1.5%到3%。这个约束用的是等号,因为调频市场出清必须保证系统的安全备用需求完全满足,如果资源不足,价格会飙升到报价上限。
3. Matlab代码实现与算例验证
3.1 代码架构与运行流程
整体代码结构我按照仿真项目的惯例分成五个模块,main脚本、参数配置、模型构建、求解与结果导出、画图。这样设计的好处是模块之间解耦,改参数不用动模型,改模型不用动参数,减轻调试负担。
要在Matlab里跑通这套程序,步骤如下:
安装Yalmip工具箱,把yalmip文件夹添加到Matlab路径,readme文件里有配置方法;如果已经有Gurobi或Cplex,按对应接口文件配置好求解器路径。
打开config_params.m,设置机组数、负荷曲线、储能参数、调频参数、报价参数等。
直接运行main.m,脚本会自动加载配置、调用fmincon或optimize构建模型并求解、输出结果变量。
运行plot_results.m,查看电能量出清价格、储能充放电计划、SOC轨迹、调频容量分配图。
我建议第一次跑的时候先用自带的小算例,规模是10台机组、24个时段、1个储能电站,求解时间大概在几秒钟到十几秒之间,取决于所选的求解器。这个规模方便对照检查结果是否合理。
3.2 关键代码段解析:储能出力与调频预留
下面贴一段核心代码。这是储能参与联合出清的约束构建代码,能直接看出充放电分离变量和调频预留约束是怎么处理的。
% 储能充放电功率变量 P_ch = sdpvar(1, T, 'full'); % 充电功率 P_dis = sdpvar(1, T, 'full'); % 放电功率 u_ch = binvar(1, T); % 充电状态0/1 u_dis = binvar(1, T); % 放电状态0/1 Q_f = sdpvar(1, T, 'full'); % 调频预留容量 SOC = sdpvar(1, T+1, 'full'); % 荷电状态(包含初始时刻) Constraints = [Constraints, P_ch >= 0, P_ch <= P_max_test * u_ch]; Constraints = [Constraints, P_dis >= 0, P_dis <= P_max_test * u_dis]; Constraints = [Constraints, u_ch + u_dis <= 1]; % SOC递推关系:充电效率与放电效率分开处理 Constraints = [Constraints, SOC(1) == SOC_init]; % SOC = 直接采用线性近似,令k1为充放电状态下的综合效率系数 for t = 1:T Constraints = [Constraints, SOC(t+1) == SOC(t) ... + eta_ch * P_ch(t) * delta_t / Cap ... - P_dis(t) * delta_t / (eta_dis * Cap)]; end % SOC上下限约束 Constraints = [Constraints, SOC_min <= SOC(1:T) <= SOC_max]; % 调频容量与充放电功率的耦合约束 Constraints = [Constraints, Q_f >= 0, Q_f <= P_max_test]; for t = 1:T Constraints = [Constraints, Q_f(t) <= P_max_test - P_dis(t) + P_ch(t)]; Constraints = [Constraints, Q_f(t) <= P_max_test + P_dis(t) - P_ch(t)]; end注意第二个Q_f约束写成Q_f(t) <= P_max + P_dis(t) - P_ch(t)的含义,这里体现的是当储能正在放电时,它向下调节(停止放电、转为充电)的能力受到P_max + P_dis(t) - P_ch(t)的限制。两个不等式合在一起,保证了预留容量不会超过正反两个方向的可调节裕度。
调频里程成本的计算放在目标函数部分,写法是:
% 调频里程成本项 Mile_cost = sum( lambda * Q_f .* mile_price );如果调频市场价格是按容量出清价格和里程出清价格分开结算的,也可以用两组价格分别乘以容量和里程。我的代码里统一为容量价格加里程价格的方式。
3.3 参考算例:参数设置与结果解读
我设了一个参考算例,参数如下:
- 时间分辨率:1小时,共24个时段
- 常规机组:2台火电机组,容量分别为300MW和500MW,报价分别是280元/MWh和350元/MWh
- 独立储能:装机100MW/200MWh,初始SOC为0.5,SOC上下限0.1/0.9,充放电效率0.95,最大充放功率100MW
- 调频需求:设定为系统预测负荷的2%,在每个时段固定
- 调频容量报价上限:80元/MW,里程报价上限:15元/MW
负荷曲线采用典型的双峰曲线,早高峰在9点到11点,晚高峰在18点到21点。电价由模型内生决定,电能量出清价格按边际机组报价形成。
跑完求解器之后,可以看到几个关键现象:
第一个现象是,在负荷低谷时段,储能基本处于充电状态,SOC不断爬升,同时预留少量调频容量;在负荷高峰时段,储能转为放电状态,SOC下降,调频预留容量会适当减少。
第二个现象是,调频容量预留的最优值并不总是上限。在电能量价格较低而调频价格相对较高的时段,模型会预留较多容量参与调频;反之在电能量价格高涨的时段,储能把大部分容量用于放电赚取峰谷价差,调频预留就压缩到满足系统需求的最小值。这说明联合出清机制确实能捕捉到两个市场之间的机会成本替代。
第三个现象是,最终出清结果里没有出现同时充放电的情况。通过状态变量的约束,模型在正常报价区间下都能找到互斥解,这验证了MILP建模的正确性。
参数对结果的影响也很明显。如果把储能初始SOC调高到0.8,在早高峰时段它可以先放电获利;如果调低到0.2,那么早高峰时段它就需要先充电蓄能,放电时段整体后移。这说明初始SOC状态在市场出清中扮演着重要角色,实际运行时需要对储能的实际SOC状态做准确上报。
4. 常见问题与调试经验速查
4.1 求解器选择和Yalmip版本兼容性问题
这套代码使用Yalmip作为建模语言,对求解器的依赖主要在MILP求解能力上。如果你用Gurobi或Cplex,基本上不需要额外设置,Yalmip会自动识别并调用。但如果你只有Matlab自带的linprog,那混合整数变量u_ch和u_dis将无法被处理,程序会报错,提示是"integer variables not supported by selected solver"。
遇到这种情况,最简单的解决方案是把问题退化为纯线性规划。具体操作是注释掉u_ch和u_dis的binvar定义,直接让P_ch和P_dis在0到P_max之间连续变化,并通过调整目标函数中充电和放电的报价来尽量避免同时充放电。但这只是权宜之计,如果要严格物理建模,还是建议装一个Gurobi或Cplex。Gurobi对学术用户免费申请license,Cplex也有社区版,我用下来Gurobi的求解速度在中小规模问题上比Cplex快一些。
Yalmip版本方面,我遇到过几个旧版本不支持某些约束写法的坑。比如早期版本对sdpvar(1, T, 'full')的三参数写法支持不完整,容易报错。建议直接从GitHub下载最新版Yalmip,或者用MATLAB的File Exchange镜像,安装时确保Yalmip路径在Matlab搜索路径的最前面,避免和旧版本冲突。
4.2 模型不可行的排查方法
跑市场出清模型最常遇到的问题就是求解器返回"Infeasible problem"。这个问题几乎每个做电力市场仿真的学生都会遇到。排查的思路一般是逐个约束检查,但在Yalmip里有个更高效的排查方法。
Yalmip提供了check函数,求解之后可以逐个检查约束的残差:
diagnostics = optimize(Constraints, Objective, options); checks = check(Constraints); [trouble, idx] = max(checks);如果约束不满足,check返回的数值会是负数或者很小的正数,对应的索引可以帮助你快速定位是哪一组约束出了问题。
在我自己的调试经验里,调频容量约束是最容易导致不可行的元凶。原因是Q_f(t) ≤ P_max - P_dis(t) + P_ch(t)这类约束在P_dis和P_ch取值较大时,可能把Q_f压到负值下界以下。比如某个时段P_dis = 90MW、P_ch = 0,那么P_max - P_dis + P_ch = 10MW,如果此时系统调频需求要求Q_f至少达到20MW,就只能用增加充电功率P_ch来扩大调节裕度,但充电功率增加又受到SOC上限限制,多重约束叠加,最终可能无解。
典型的解决方法是在目标函数里加一个松弛变量,例如设置调频容量不足是可以接受的,但会产生惩罚成本:
Q_slack = sdpvar(1, T, 'full'); Constraints = [Constraints, Q_f + Q_slack >= D_reg]; Objective = Objective + 100000 * sum(Q_slack);这个技巧在实际工程里非常常用,它能让你在调试初期就先看到模型在其他约束下的行为,等确认其它模块无误后再把松弛变量去掉,恢复严格出清。
4.3 出清价格计算和储能结算逻辑的易错点
联合出清的价格计算是很多代码实现时容易出错的地方。调频辅助服务市场出清价格通常不直接是优化对偶变量,尤其当有容量价格上限和里程价格上限约束时,价格形成机制更复杂。
我的代码里,电能量出清价格通过获取平衡约束的对偶乘子得到。在Yalmip中,可以通过dual函数获取某个约束的对偶值。例如功率平衡约束是:
Balance_con = [Balance_con, sum(P_g(:,t)) + P_dis(t) - P_ch(t) == D_load(t)];则λ_load(t) = dual(Balance_con(t))即为该时段的电能量出清价格。注意如果使用linprog,对偶信息也能获取,但需要把问题转为标准形式,比较麻烦。
储能参与两个市场的结算收益是另一个容易搞混的点。储能在电能量市场的收益是放电收入减充电支出;在调频市场的收益是调频容量收入加里程收入。但要注意使用同一组价格时,不能把机会成本重复计算。仿真结果里我习惯把储能收益拆成三列:电能量套利收益、调频容量收益、调频里程收益,这样便于分析它在每个市场里的经济性贡献。
4.4 调频里程系数λ的标定经验
最后提一下调频里程系数λ的取值问题。很多文献里对λ的处理是直接取固定值2,但这个做法在特定系统里会产生明显偏差。
λ的实际含义是单位调频容量期望完成的调节里程,它和系统的频率质量指标强相关。如果系统ACE较小、CPS指标高,λ通常偏低;如果新能源出力的随机波动大,λ会显著上升。在做仿真时,建议先用历史运行数据估算λ:采集一段时间内的AGC指令和机组实际响应里程数据,统计总里程与总调频容量的比值。
我在模型的config文件里默认了λ = 2,同时提供了灵敏度分析模式。用户可以把λ从1.5到3.0按步长0.25逐步测试,观察储能收益的变化规律。实测结果表明,λ每增加0.5,储能在调频市场的收益大约能增加20%到30%,这会反过来影响电能量市场和调频市场之间的容量分配,因此λ标定对整个出清结果影响很大,不能随意填写。
5. 从算例到扩展:机制还可以怎么改
代码跑通只是第一步,后续的机制扩展空间非常大。我在这套框架上做过几个方向的延伸,效果都不错。
第一个方向是加入储能的退化成本模型。电池每完成一次充放电循环和每次调频调节,都会消耗循环寿命。在目标函数里加入状态相关的退化成本后,储能的最优出力会明显变得保守:充放电深度变小、调频出力更平滑,算出来的设备寿命也更接近实际情况。退化成本系数可以通过储能厂商的循环寿命曲线拟合得到。
第二个方向是多储能协同。当系统里有多个独立储能电站时,它们之间存在调频容量和电能量的相互竞争,这实际上是一个多参与者博弈问题。在出清框架里,把多储能各自的SOC和容量约束并列加入模型,可以观察市场出清结果对不同储能初始SOC、功率容量参数的敏感性,这对接入电网的储能规划研究有参考价值。
第三个方向是加入网络约束。现货电能量市场通常要满足输电线路的潮流约束,我的代码简化处理为单节点模型,如果要做区域市场或节点电价分析,需要在模型中加入直流潮流方程,在Yalmip里这并不难,用转移分布因子矩阵或者导纳矩阵即可。
我认为这套代码最大的价值是让"电能量+调频"协调出清这个抽象概念变成了一个可以反复实验、改参数、换场景的仿真平台。拿到代码后先跑通自带算例,再逐步修改约束和目标函数,就能对市场机制有非常直观的理解。如果你在跑代码过程中遇到不可行、价格异常、SOC溢出之类的问题,很大概率就在我上面讲过的几个坑里,按速查表排查一轮基本都能解决。
我自己把这套模型从单时段静态优化改到多时段动态出清,从单节点改到IEEE 14节点系统(需要额外补潮流数据),前前后后折腾了两周,踩过的坑包括MILP变量初始化问题、极端报价导致的数值病态、还有求解器超时设置,这些经验都写进代码注释了。建议你在使用的时候先从benchmark案例开始,逐项打开功能开关,每一步都确认结果合理再进入下一步,别一次性把所有复杂约束都加上,否则出问题根本定位不到源头。