ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

储能参与电力市场双层优化:现货与调频交易策略的Matlab实现

储能参与电力市场双层优化:现货与调频交易策略的Matlab实现 储能怎么赚钱这事儿做电力系统优化的人基本都绕不开。单纯靠峰谷价差套利算下来投资回报率往往不太好看——现货电能量市场的价格波动再大一天也就几个高峰几个低谷电池充放次数摆在那里收益天花板很明显。所以业内这几年集体把目光盯上了调频辅助服务市场同样是一度电在电能量市场里赚的是峰谷价差在调频市场里赚的是里程补偿和容量补偿两个市场的收益逻辑完全不一样叠加起来才可能把储能的经济性算明白。但问题也出在这里——两个市场共享同一个储能容量充放电功率怎么分配电能量价格和调频出清价格又是联动的到底先出清哪个你报的调频容量会不会影响电能量市场的结算这些用单层优化模型根本说不清楚。我这次要拆解的研究项目就是用双层优化把这笔账算清楚上层做储能的交易决策下层模拟现货电能量市场和调频辅助服务市场的联合出清整套流程用Matlab实现。适合谁看做储能电站经济性评估的工程师、研究电力市场出清模型的硕博生、想用YalmipGurobi求解双层优化问题的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_{t1}^{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_{t1} 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_{t1}^{T} \sum_{g1}^{G} C_g \cdot P_{g,t} ]约束包括系统功率平衡、发电机组上下限、线路潮流约束、储能申报的充放电功率也参与平衡。调频辅助服务市场则通常与电能量市场联合优化在目标函数中加入调频容量和里程成本[ \min \sum_{t1}^{T} \left( \sum_{g1}^{G} C_g \cdot P_{g,t} \sum_{g1}^{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 # 调用YalmipGurobi求解 ├── 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(T1, 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(t1) 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(T1) 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元/MWhM取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×496时段会非常吃力。我实际测试下来1小时粒度T24在场景数5以内可以接受15分钟粒度T96即使场景数降到3个也经常要跑几个小时。建议先用小时粒度跑通逻辑再细化到15分钟。MIPGap设置。双层MILP问题由于含大量0-1变量最优解证明很耗时。设置 MIPGap0.01即1%通常足够——因为市场出清价本身是近似模型精确到1%以内的收益差距在工程上没有实际意义。我见过有些人死磕 MIPGap0.0001结果程序跑了两天没出结果完全没有必要。Big-M取值。这块我再强调一遍因为踩过的坑太深。M太小落在可行域之外会把真正的部分排掉M太大求解器的预处理阶段会出现大系数矩阵导致对偶问题数值不稳定。一个可行做法先用线性规划跑一遍不带互补约束的下层问题记录对偶变量的量级范围然后设置M为这个范围的10~100倍。初始可行解。给Gurobi提供一个初始可行解能大幅提升求解速度。我通常先忽略互补约束解一个松弛版本把得到的变量值作为初始解传给原始MILP。4.3 计算时间与解的精度权衡从我实测角度看这个双层模型的求解时间是所有参数最敏感的指标。下面是我在一台i7-12700、32GB内存机器上的典型测试结果场景数时段数上层变量数下层变量数求解时间MIPGap32472约500约2分钟1%524120约800约15分钟1%824192约1300约1小时1%848384约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并行求解每个场景的MILP5.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变成MILPKKT条件转化就不成立了。这种情况下工程上常用的替代方案是用松弛线性化把机组组合问题近似为一个可微凸问题或者用启发式算法如PSO、遗传算法在外层迭代逼近最优策略。我自己的体会是双层优化这类问题80%的时间都花在模型调试和结果合理性校验上真正的数学推导和编码反而是相对机械的。跑出来的结果如果不符合物理直觉不要急着调参数先回头看看是不是某个对偶变量符号反了、某个互补条件漏了。这个框架搭好之后换数据、换市场规则、换储能参数都是很快的事情——底层逻辑扎实了上层才能灵活。最后提醒一句Gurobi跑这种MILP记得把TimeLimit和MIPGap设好别让它无限跑下去不然一觉醒来程序还在转你哭都来不及。
返回列表