储能怎么赚钱,这事儿做电力系统优化的人基本都绕不开。单纯靠峰谷价差套利,算下来投资回报率往往不太好看——现货电能量市场的价格波动再大,一天也就几个高峰几个低谷,电池充放次数摆在那里,收益天花板很明显。所以业内这几年集体把目光盯上了调频辅助服务市场:同样是一度电,在电能量市场里赚的是峰谷价差,在调频市场里赚的是里程补偿和容量补偿,两个市场的收益逻辑完全不一样,叠加起来才可能把储能的经济性算明白。
但问题也出在这里——两个市场共享同一个储能容量,充放电功率怎么分配?电能量价格和调频出清价格又是联动的,到底先出清哪个?你报的调频容量会不会影响电能量市场的结算?这些用单层优化模型根本说不清楚。我这次要拆解的研究项目,就是用双层优化把这笔账算清楚:上层做储能的交易决策,下层模拟现货电能量市场和调频辅助服务市场的联合出清,整套流程用Matlab实现。
适合谁看:做储能电站经济性评估的工程师、研究电力市场出清模型的硕博生、想用Yalmip+Gurobi求解双层优化问题的Matlab用户。我会把建模思路、代码框架、关键参数的坑全部摊开讲。
1. 为什么储能交易决策非得用双层模型
1.1 单层模型的迷惑性:你以为是企业在做决策,实际是市场在做决策
很多刚接触这个方向的人会问:储能参与市场不就是个优化问题吗?给定现货价格曲线和调频出清价格,直接建一个混合整数线性规划(MILP),最大化收益不就行了?
看起来简单,但你忽略了一个关键事实:储能的申报策略会反过来影响市场出清价格。如果一个区域储能渗透率较高,你在某个时段集中放电,那这个时段的供给曲线会被压低,现货价格可能从500元/MWh掉到300元/MWh。你按500元价格做的最优决策,实际结算时只能拿到300元——这就是典型的“价格接受者”假设失效。
单层模型只能做“价格接受者”的优化,把出清价格当作固定参数。而双层模型把市场出清作为下层问题嵌入进去,让上层决策(储能的申报策略)能感知到自己对价格的影响,这才是更接近真实市场环境的建模方式。
简单的类比:单层模型像是你按照餐厅菜单价格点菜,双层模型像是你意识到自己是大客户,点菜量大到能影响餐厅的定价策略,于是点菜和定价形成了一个博弈。
1.2 现货市场与调频市场的时间尺度错配
这里还要说清楚一个本质问题——为什么不能把现货电能量和调频辅助服务放在一个市场里直接出清?
现货电能量市场(通常是日前市场)以15分钟到1小时为一个时段,解决的是“每个时段发多少电、用多少电”的问题,出清变量是各节点的电量电价。而调频辅助服务市场解决的是“在实时运行中如何维持系统频率稳定”的问题,出清变量是调频容量和调频里程价格。两个市场虽然共用一套发电资源,但时间尺度和物理特性完全不同。
储能恰恰是唯一能同时高效参与这两个市场的资源:它响应速度快(分钟级甚至秒级),调节精度高,而且可以灵活切换充放电状态。但这也带来一个棘手问题——储能的容量/能量是有限的,多分配给调频市场一分容量,电能量市场的可用容量就少一分。这就需要上层决策变量同时包含两个市场的申报量,并且通过SOC(荷电状态)约束把它们耦合起来。
1.3 双层模型的价值:把“市场力”显式表达出来
用双层模型的另一个动机,是研究储能作为价格影响者(甚至局部市场力持有者)的策略空间。
当储能规模足够大时,它可以通过改变申报策略影响市场出清价。上层储能运营商是这层博弈的领导者(Leader),下层的市场出清模型是跟随者(Follower)。领导者决策时会预测跟随者的响应,这种Stackelberg博弈结构,正是双层优化的标准形态。
举个实际场景:某时段系统正值晚高峰,现货价格预期很高,储能想着这时候放电赚差价。但如果你在日前市场申报了很大的放电量,可能拉低这个时段的出清价格;同时,你申报调频容量也会影响电能量市场的可用容量申报,从而影响出清价。这些都是单层模型看不见的“暗流”。
2. 双层交易决策问题的数学建模思路
2.1 上层模型:储能运营商的收益最大化
上层问题的主角是储能运营商。假设运营一个独立储能电站,容量为E_max,额定功率为P_max。决策变量包括:
- 每个时段的充电功率 ( P_{t}^{ch} ) 和放电功率 ( P_{t}^{dis} )
- 每个时段申报的调频容量 ( R_{t} )
- 每个时段的调频里程比例 ( M_{t} )(通常由市场规则给定或按历史数据估计)
上层目标函数是最大化总收益:
[ \max \sum_{t=1}^{T} \left[ \lambda_{t}^{DA} \cdot (P_{t}^{dis} - P_{t}^{ch}) \cdot \Delta t + \lambda_{t}^{REG} \cdot R_{t} + \lambda_{t}^{MIL} \cdot M_{t} \cdot R_{t} - c_{op} \cdot (P_{t}^{ch} + P_{t}^{dis}) \right] ]
其中:
- ( \lambda_{t}^{DA} ) 是现货电能量市场的出清价格(由下层出清得到)
- ( \lambda_{t}^{REG} ) 是调频容量价格(由下层调频市场出清得到)
- ( \lambda_{t}^{MIL} ) 是调频里程价格
- ( c_{op} ) 是单位充放电的运维成本(折算后的电池损耗)
约束条件包括:
- SOC动态约束:( SOC_{t+1} = SOC_t + \eta_{ch} P_{t}^{ch} \Delta t - P_{t}^{dis} \Delta t / \eta_{dis} )
- SOC上下限:( SOC_{min} \le SOC_t \le SOC_{max} )
- 功率约束:( 0 \le P_{t}^{ch} \le P_{max} ),( 0 \le P_{t}^{dis} \le P_{max} )
- 调频容量约束:( 0 \le R_t \le R_{max} ),且 ( P_{t}^{ch} + R_t \le P_{max} ),( P_{t}^{dis} + R_t \le P_{max} )(这表示调频容量和充放电功率共享同一个变流器容量)
关键点在于:下层出清价格 ( \lambda_{t}^{DA} )、( \lambda_{t}^{REG} ) 并不是常数,而是下层市场出清问题的解。这样一来,上层决策变量就通过下层问题的KKT条件间接影响价格——这就是双层耦合的本质。
2.2 下层模型:现货电能量与调频市场的联合出清
下层模型模拟市场运营机构的出清过程。为简化,可以把现货电能量市场近似为一个基于节点电价的经济调度问题:
[ \min \sum_{t=1}^{T} \sum_{g=1}^{G} C_g \cdot P_{g,t} ]
约束包括系统功率平衡、发电机组上下限、线路潮流约束、储能申报的充放电功率也参与平衡。
调频辅助服务市场则通常与电能量市场联合优化,在目标函数中加入调频容量和里程成本:
[ \min \sum_{t=1}^{T} \left( \sum_{g=1}^{G} C_g \cdot P_{g,t} + \sum_{g=1}^{G} C_g^{REG} \cdot R_{g,t} + C_{storage}^{REG} \cdot R_t \right) ]
约束增加系统调频容量需求约束:( \sum_g R_{g,t} + R_t \ge D_t^{REG} )。
这里的核心逻辑是:下层模型把储能的申报量作为参数,出清得到市场价格和结算量。下层问题的变量包括各发电机的出力、调频容量分配、节点电价(对偶变量)等。
2.3 双层问题的求解:KKT条件转化的套路
双层优化无法直接求解,工程上最常用的方法就是把下层问题用KKT条件替换,转化为单层均衡约束数学规划(MPEC),再进一步线性化为MILP。
具体步骤:
- 写出下层问题的拉格朗日函数
- 对下层变量求偏导,得到驻点条件(稳定性条件)
- 补充原问题的可行性约束和对偶可行性约束
- 处理互补松弛条件(乘积为0的约束)
- 互补条件通过大M法线性化: [ 0 \le \mu \perp (Ax - b) \ge 0 \Rightarrow \mu \le M z, \quad Ax - b \le M(1-z) ] 其中z是0-1变量,M是一个足够大的常数。
做完这一步,整个问题就变成了一个MILP,可以直接用Gurobi或CPLEX求解。这里要注意:如果下层问题是凸的线性规划或二次规划,KKT条件是充分必要条件,替换是严格的等价的;如果下层问题非凸,那KKT条件只是必要条件,结果可能只是局部最优或一个候选解。
3. Matlab代码实现:框架设计与核心模块解析
3.1 整体文件结构与数据流程
我习惯按以下结构组织整个Matlab工程:
project/ ├── main.m # 主运行脚本 ├── data/ │ ├── load_data.m # 负荷、新能源、价格历史数据 │ └── market_params.m # 市场参数(如调频需求、里程比例) ├── models/ │ ├── build_upper.m # 上层储能决策模型 │ ├── build_lower.m # 下层市场出清模型 │ └── kkt_linearization.m # KKT条件转化与线性化 ├── solvers/ │ └── solve_bilevel.m # 调用Yalmip+Gurobi求解 ├── results/ │ └── plot_results.m # 结果可视化 └── utils/ ├── scenario_cluster.m # 场景削减 └── soc_calc.m # SOC计算辅助函数核心数据流:历史负荷、新能源出力等原始数据 → 场景聚类削减 → 生成典型场景 → 构建双层优化模型 → KKT转化 → MILP求解 → 结果后处理和可视化。
3.2 场景生成与削减
双层MILP的计算时间承受不了直接跑365×24小时的完整数据。我用K-means聚类把历史数据缩减到5~10个典型场景。
场景削减的代码如下:
% 场景削减:K-means聚类 % data_matrix 每行是一个场景(含负荷、新能源、现货价格、调频价格) % 行数为天数,列数为时段数×变量数 [idx, C] = kmeans(data_matrix, n_scenarios, 'MaxIter', 500); prob = histcounts(idx, n_scenarios) / length(idx); % 场景概率这里有一个经验值:场景数设为5~8个通常就能覆盖价格形态的主要差异。超过10个场景,MILP的求解时间会指数级上升,收益结果提升却非常有限。聚类的最大迭代次数建议至少500次,否则容易陷入局部最优。
3.3 上层模型的核心代码
Yalmip建模用起来比直接写CPLEX接口舒服得多。上层模型核心代码如下:
% 决策变量 P_ch = sdpvar(T, 1); % 充电功率 P_dis = sdpvar(T, 1); % 放电功率 R_reg = sdpvar(T, 1); % 申报调频容量 SOC = sdpvar(T+1, 1); % SOC状态,首时段为初始值 % 目标函数中的价格是下层出清得到的变量,此处先声明 lambda_DA = sdpvar(T, 1); % 现货电能量价格 lambda_REG = sdpvar(T, 1); % 调频容量价格 lambda_MIL = sdpvar(T, 1); % 调频里程价格 % 上层目标函数 Revenue_DA = sum(lambda_DA .* (P_dis - P_ch) * delta_t); Revenue_REG = sum(lambda_REG .* R_reg); Revenue_MIL = sum(lambda_MIL .* mileage_rate .* R_reg); Cost_Ope = sum(c_op * (P_ch + P_dis)); Objective = -(Revenue_DA + Revenue_REG + Revenue_MIL - Cost_Ope); % 约束 Constraints = []; Constraints = [Constraints, SOC(1) == SOC_init]; for t = 1:T Constraints = [Constraints, SOC(t+1) == SOC(t) ... + eta_ch * P_ch(t) * delta_t - P_dis(t) * delta_t / eta_dis]; Constraints = [Constraints, SOC_min <= SOC(t) <= SOC_max]; Constraints = [Constraints, 0 <= P_ch(t) <= P_rated]; Constraints = [Constraints, 0 <= P_dis(t) <= P_rated]; Constraints = [Constraints, 0 <= R_reg(t) <= R_rated]; Constraints = [Constraints, P_ch(t) + R_reg(t) <= P_rated]; Constraints = [Constraints, P_dis(t) + R_reg(t) <= P_rated]; end Constraints = [Constraints, SOC(T+1) == SOC_init]; % 周期末恢复初始SOC注意SOC(1) == SOC_init用于设定初始荷电状态,末时段强制回到初始值,这样在多日滚动调度里不会出现“把电放光”的投机行为。
3.4 下层模型与KKT转化
下层出清模型是线性规划,核心代码如下:
% 下层变量:发电机组出力P_g、调频容量R_g、储能充放电(由上层传递) P_g = sdpvar(G, T); R_g = sdpvar(G, T); theta = sdpvar(N_bus, T); % 节点相角(简化直流潮流用) % 下层目标函数:系统总成本最小 obj_lower = sum(sum(C_g .* P_g)) + sum(sum(C_g_reg .* R_g)) + sum(lambda_reg_storage .* R_reg_upper); % 约束 Constraints_L = []; Constraints_L = [Constraints_L, sum(P_g, 1) + (P_dis_upper - P_ch_upper) == demand']; % 功率平衡 Constraints_L = [Constraints_L, P_g_min <= P_g <= P_g_max]; Constraints_L = [Constraints_L, R_g_min <= R_g <= R_g_max]; Constraints_L = [Constraints_L, sum(R_g, 1) + R_reg_upper >= reg_demand']; % 调频容量需求 % ... 其他网络约束转化KKT条件时,对于线性规划,可以直接利用对偶变量和互补松弛条件。关键代码段:
% KKT条件:对P_g求导的拉格朗日条件 Stationarity_Pg = C_g - lambda_balance + mu_g_max - mu_g_min == 0; % mu_g_max 和 mu_g_min 是发电出力上下限约束的对偶变量 % 互补松弛条件(双线性项,需要大M法线性化) Complementarity_1 = mu_g_max' * (P_g - P_g_max) == 0; Complementarity_2 = mu_g_min' * (P_g_min - P_g) == 0;大M法线性化的实际代码:
% 每一条互补条件引入0-1变量z M = 1e6; % 大M值 z = binvar(size(mu_g_max)); Constraints = [Constraints, mu_g_max <= M * z]; Constraints = [Constraints, P_g - P_g_max <= M * (1 - z)]; Constraints = [Constraints, mu_g_max >= 0, P_g - P_g_max >= 0];大M值不是越大越好。M太小时可能截断最优解,导致结果不准确;M太大时数值条件变差,求解器容易出现“numerical trouble”。我的经验是取预期价格范围的100~1000倍,比如价格范围0~1000元/MWh,M取1e5到1e6之间比较合适。
3.5 主求解循环与结果输出
% Yalmip模型整合 Constraints = [Constraints_U, Constraints_L_KKT]; Objective = Objective_U; % 求解设置 options = sdpsettings('solver', 'gurobi', 'verbose', 2); options.gurobi.TimeLimit = 3600; % 1小时求解时限 options.gurobi.MIPGap = 0.01; % MIP间隙1%以内 % 求解 sol = optimize(Constraints, Objective, options); if sol.problem == 0 % 求解成功,后处理结果 P_ch_opt = value(P_ch); P_dis_opt = value(P_dis); R_reg_opt = value(R_reg); SOC_opt = value(SOC); else % 求解失败,输出错误信息 disp(sol.info); diagnose(Constraints, Objective); end这里diagnose是Yalmip自带的调试命令,当模型求解失败时,它能帮你定位到底是约束不可行、变量无界还是数值问题。
4. 关键参数设置与求解器选型经验
4.1 求解器怎么选
双层MILP问题,我实际测试过三种求解方案:
| 方案 | 工具链 | 适用规模 | 备注 |
|---|---|---|---|
| 方案一 | Yalmip + Gurobi | 场景数≤10,时段数≤48 | 首选,求解速度快,MIPGap控制好 |
| 方案二 | Yalmip + CPLEX | 同上 | 与Gurobi差异不大,部分模型CPLEX数值稳定性略好 |
| 方案三 | 纯Yalmip + 内置sdp | 只适合极小的算例 | 几乎不可用,变量一多就卡死 |
个人建议直接上Gurobi,学术许可免费,MILP求解器性能目前仍然是第一梯队。Yalmip 的安装非常简单,下载后加入Matlab路径即可:
addpath(genpath('D:/yalmip')); savepath;Gurobi 需要注意版本兼容性——不是所有Gurobi版本都能被当前Yalmip版本识别。我建议用Yalmip 2023年之后的版本搭配Gurobi 10.x,兼容性问题最少。
4.2 几个直接影响求解性能的参数
时段粒度。现货市场通常15分钟一个结算时段,但双层MILP跑24×4=96时段会非常吃力。我实际测试下来,1小时粒度(T=24)在场景数5以内可以接受,15分钟粒度(T=96)即使场景数降到3个也经常要跑几个小时。建议先用小时粒度跑通逻辑,再细化到15分钟。
MIPGap设置。双层MILP问题由于含大量0-1变量,最优解证明很耗时。设置 MIPGap=0.01(即1%)通常足够——因为市场出清价本身是近似模型,精确到1%以内的收益差距在工程上没有实际意义。我见过有些人死磕 MIPGap=0.0001,结果程序跑了两天没出结果,完全没有必要。
Big-M取值。这块我再强调一遍,因为踩过的坑太深。M太小,落在可行域之外会把真正的部分排掉;M太大,求解器的预处理阶段会出现大系数矩阵,导致对偶问题数值不稳定。一个可行做法:先用线性规划跑一遍不带互补约束的下层问题,记录对偶变量的量级范围,然后设置M为这个范围的10~100倍。
初始可行解。给Gurobi提供一个初始可行解能大幅提升求解速度。我通常先忽略互补约束,解一个松弛版本,把得到的变量值作为初始解传给原始MILP。
4.3 计算时间与解的精度权衡
从我实测角度看,这个双层模型的求解时间是所有参数最敏感的指标。下面是我在一台i7-12700、32GB内存机器上的典型测试结果:
| 场景数 | 时段数 | 上层变量数 | 下层变量数 | 求解时间 | MIPGap |
|---|---|---|---|---|---|
| 3 | 24 | 72 | 约500 | 约2分钟 | 1% |
| 5 | 24 | 120 | 约800 | 约15分钟 | 1% |
| 8 | 24 | 192 | 约1300 | 约1小时 | 1% |
| 8 | 48 | 384 | 约2600 | 数小时以上 | 1% |
如果你的场景数和时段数组合远超这个范围,建议先做场景削减和时段聚合,而不是盲目扩充硬件。
5. 实操中的常见问题与调试经验
5.1 模型不可行:先检查SOC约束
如果求解器报“infeasible”,我第一步永远检查SOC约束。最容易出问题的地方是:周期末强制SOC回到初始值。当储能容量太小而负荷/价格波动太大时,这个约束可能把整个模型锁死。
临时解决办法:先注释掉末时段SOC恢复约束,看看其余约束是否可行。 长期解决办法:增加储能容量,或者把末时段SOC设定为一个范围(比如40%~60%)而不是固定值。
5.2 求解器报“Nonconvex”或“Quadratic constraint”
双层模型经过KKT转化后,如果某个地方出现了两个变量相乘(例如价格乘电量),就会产生双线性项。一旦Yalmip检测到非凸二次约束,求解器通常会直接拒算。
排查思路:
- 检查是否所有互补约束都用大M法线性化了
- 检查目标函数中是否有
x'*Q*x这种二次项 - 用
check(Constraints)检查所有约束的类型
最常见的遗漏是:上层目标函数中的收益项是lambda .* P_dis,其中lambda是下层对偶变量,P是上层决策变量——两者相乘就是双线性!解决办法是把这些乘积项线性化(通过强对偶定理替换,或者用 Fortuny-Amat 大M法对双线性项做 McCormick 松弛)。
5.3 结果不合理:储能收益为负或者出清价完全被扭曲
如果模型算出来的储能收益为负,大概率是目标函数符号写反了,或者价格变量的正负号定义错了。检查一下:出清价格对偶变量的非负约束是否加上?如果Lambda_DA负数问题,通常遗漏了价格变量的非负约束。
如果价格被严重扭曲(比如储能一放电价格直接跌到0),说明储能申报容量占市场总量比例过大,这时需要加入市场力限制约束,例如限制储能申报量不得超过市场总需求的某个比例(比如20%)。
5.4 求解时间无法接受:从模型层面做减法
碰到求解时间爆炸时,我习惯按以下顺序做减法,每做一步就重新测试:
- 场景削减:用K-means把20个场景砍到5个
- 时段聚合:96时段改为24时段
- 简化网络拓扑:IEEE 30节点改为单节点(仅功率平衡无网络约束)
- 固定部分变量:如果调频容量主要由少数机组提供,可以固定部分机组的调频变量,只保留关键机组的决策空间
- 并行计算:如果跑多场景,用
parfor并行求解每个场景的MILP
5.5 双层问题常见问题速查表
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 报错“infeasible” | SOC末值约束太紧、潮流约束冲突 | 松绑SOC末值、检查功率平衡约束 |
| 报错“Nonconvex QP” | 双线性项未线性化 | 用大M法或McCormick松弛 |
| 求解时间过长 | 场景数过多、MIPGap过小 | 削减场景、放宽MIPGap到1% |
| 价格为负 | 对偶变量符号错误 | 检查非负约束,修正KKT转化符号 |
| 储能收益为负 | 目标函数符号写反 | 检查max/min方向与变量定义 |
| Gurobi报“numerical trouble” | Big-M值过大 | 缩小M值到合理范围 |
6. 项目后续还能怎么扩展
这个双层框架搭起来之后,扩展空间其实很大。
6.1 加入不确定性:两阶段随机规划
现货价格、调频里程指令都具有较强的不确定性。当前模型假定储能里程比例是固定常数,但实际运行中,调频里程是根据系统频率偏差实时波动的。扩展方向:把不确定参数用场景树描述,上层决策分为“日前申报”(第一阶段的现在决策)和“实时调整”(第二阶段的等待之后决策),形成两阶段随机双层优化。
6.2 储能寿命损耗建模
把电池循环寿命折算进运维成本,本质上是一个简单的线性近似:每次充放电产生损耗成本 ( C_{degradation} = \alpha \cdot (P_{dis} + P_{ch}) )。更精细的做法是把DoD(放电深度)纳入寿命模型,让损耗成本与SOC变化量挂钩,这样储能在高SOC区间的频繁充放会受到惩罚,结果会更贴近电池实际运行约束。
6.3 多储能主体的博弈模型
单个储能作为领导者是Stackelberg博弈的简化情形。如果系统内有多个储能运营商,它们之间互相竞争,问题就变成了均衡问题(EPEC)。这类问题求解难度更高,通常用对角化算法迭代求解:固定其他储能策略,依次求解每个储能的MPEC,循环迭代到收敛。
6.4 与机组组合的耦合
机器组合(UC)问题中,火电机组的启停状态、最小运行时间等整数约束如果进入下层模型,下层问题就从LP变成MILP,KKT条件转化就不成立了。这种情况下,工程上常用的替代方案是:用松弛线性化把机组组合问题近似为一个可微凸问题,或者用启发式算法(如PSO、遗传算法)在外层迭代逼近最优策略。
我自己的体会是,双层优化这类问题,80%的时间都花在模型调试和结果合理性校验上,真正的数学推导和编码反而是相对机械的。跑出来的结果如果不符合物理直觉,不要急着调参数,先回头看看是不是某个对偶变量符号反了、某个互补条件漏了。这个框架搭好之后,换数据、换市场规则、换储能参数都是很快的事情——底层逻辑扎实了,上层才能灵活。最后提醒一句:Gurobi跑这种MILP,记得把TimeLimit和MIPGap设好,别让它无限跑下去,不然一觉醒来程序还在转,你哭都来不及。