做电动汽车聚合商参与电力市场竞标这个方向的时候,我一开始也动过偷懒的念头:把日前市场竞标和实时市场调平拆成两个独立的优化问题,各自用MATLAB优化工具箱跑一遍,再把两条曲线拼起来。结果自然是挨了一顿骂——你这两条曲线之间根本没有博弈关系,日前报得再多,实时市场电价一变,收益模型立刻崩掉。说白了,日前竞标量会直接影响实时的市场出清价,而实时电价又反过来决定日前竞标的每一度电到底划不划算,这是一个鸡生蛋、蛋生鸡的循环。要老实按这个逻辑建模,就必须上双层优化:上层是电动汽车聚合商的竞标决策,下层是市场运营机构的出清过程。本文要讲的,就是“基于双层优化的电动汽车日前与实时两阶段市场竞标策略”在MATLAB里的完整落地路径,从数学建模到KKT转换,从YALMIP代码工程到结果验证,全程跟一遍,力求让拿到代码的人能改、能跑、能答辩。
1. 为什么竞标模型必须长成“双层”:两道市场之间的博弈逻辑
1.1 日前与实时:同一块电的两次定价
先理清两道市场的基本玩法。日前市场(Day-Ahead Market)在运行日前一天闭市,聚合商要在闭市前申报第二天的充放电能量块,市场统一出清后给出每小时的日前出清价和电量计划。实时市场(Real-Time Market)则是在运行当天滚动进行,用来消化负荷预测偏差、风电出力偏差和机组突发停运,出清周期短到几分钟到一小时不等,价格波动也明显更剧烈。
这两道市场不是两张互不相关的表。对同一个聚合商来说,日前申报量不仅锁定了部分电量收益,还作为实时出清模型的输入偏差项参与价格决定。如果你在日前报了一个比较大的放电功率,实时系统中等效净负荷就变小了,边际电价会被压下去;反过来,如果你日前只报了一点点,实时电价就可能被拉高。也就是说,你的“日前决策”确实在改变你将要面临的“实时价格”,这是双层问题最核心的特征。
1.2 聚合商从价格接受者到价格影响者
很多教科书里的市场模型把参与者默认为价格接受者,直接拿预测电价做优化就行。这一假设在小规模用户侧勉强可用,但放到大规模电动汽车聚合场景就会失真。一个拥有几千辆电动汽车的聚合商,在V2G模式下可调功率动辄几十兆瓦,已经足以改变局部市场的边际机组和出清价格。尤其随着2030年V2G渗透率被各类政策目标不断抬高,聚合商再也不能假装自己的投标行为不影响市场价格。
双层优化里的“上层”,就是这个价格影响者;下层则是以社会福利最大化或购电费用最小化为目标的市场出清模型。两层之间通过投标量和出清电价互相咬合:上层先决策,下层在给定决策下出清,出清价再回到上层决定收益。这种“领导者—跟随者”结构,在博弈论里就是典型的Stackelberg博弈。
1.3 硬拆成单层模型的三个翻车现场
我在前期探索里踩过三种典型错误,写出来给大家避坑。
第一种是只做日前优化:拿日前预测电价,求解一个24小时充放电计划,然后假设实时市场不存在偏差。这种模型给出的收益往往虚高,因为它忽略了实时电价的波动和实际调整带来的惩罚性结算,一旦把实时市场加进来,日前计划可能根本不是最优。
第二种是只做实时优化:忽略日前锁定,把所有电量都放到实时市场交易。这样可以在实时价格高的时候获取暴利,但实时价格低的时候又没有日前保底,整体风险敞口过大,结果通常是一条剧烈波动的竞标曲线,不具备工程可行性。
第三种是“先日前、后实时”的顺序优化:先把日前市场当作独立问题求解,得到日前竞标曲线,再把这个曲线当作固定参数带入实时市场重新优化。看似做了两阶段,实则把第一阶段决策当成了外生变量,完全没有体现日前申报量对实时出清价的反向影响。这就是典型的“双层被拆成单层”,审稿人一眼就能看出来。
这三次翻车让我意识到:不是模型难,而是博弈关系没有建模进去。所以后面老老实实回到双层框架,把上层利润、下层出清、耦合变量一次性写清楚。
2. 双层优化模型的数学形式:上层算收益,下层算价格
2.1 上层目标与约束:聚合商的利润最大化
把模型具体化。假设聚合商管理的是规模为(N_{EV})的电动车群,每个时段(t=1,\dots,24),聚合商需要在日前市场申报注入功率(P_{DA,t})(正值表示放电,负值表示充电)。到了实时阶段,由于电价具有不确定性,聚合商在不同实时场景(s)下可以调整实际注入功率(P_{RT,s,t}),偏差量(P_{RT,s,t}-P_{DA,t})按实时电价结算。
上层目标函数写成:
[ \max ; \sum_{t=1}^{24} \left[ \lambda_{DA,t} P_{DA,t} + \sum_{s} \pi_s \lambda_{RT,s,t} (P_{RT,s,t} - P_{DA,t}) - C_{battery} \right] ]
其中(\lambda_{DA,t})是日前出清价(这里作为已知输入参数),(\lambda_{RT,s,t})是场景(s)下时段的实时出清电价,(\pi_s)为场景概率,(C_{battery})表示电池退化成本。这里电池退化用二次惩罚近似:
[ C_{battery} = \sum_t \sum_s \pi_s \cdot \alpha \cdot P_{RT,s,t}^2 ]
上层约束包含三块:充放电互斥约束、SOC递推约束、日前竞标量上下限。SOC递推写为:
[ SOC_{t+1,s} = SOC_{t,s} - \frac{P_{RT,s,t} \cdot \Delta t}{E_{cap}} + \frac{E_{drive,s,t}}{E_{cap}} ]
(E_{drive,s,t})是场景下该时段的出行耗电量,(E_{cap})是聚合后的电池总容量。SOC还要限制在安全区间内,比如0.2到0.9。
2.2 下层模型:实时市场出清的最小成本问题
下层是实时市场出清模型。为了突出双层耦合,我这里用一个简化但严谨的单节点市场:系统中有若干台常规机组(g),成本函数为二次型(a_g P_g^2 + b_g P_g),出清过程求解机组出力使得总发电成本最小,同时满足系统功率平衡。
[ \min_{P_g} ; \sum_g (a_g P_g^2 + b_g P_g) ]
[ \text{s.t.} \quad \sum_g P_g = D_{s,t} - (P_{RT,s,t} - P_{DA,t}) ]
[ 0 \le P_g \le P_g^{\max} ]
这里平衡约束的右侧是我故意加的:系统总负荷(D_{s,t})减去聚合商的净注入偏差(P_{RT,s,t} - P_{DA,t}),就是这个时段需要常规机组承担的功率。(P_{DA,t})出现在下层平衡约束里,正是上下层的耦合点。而出清得到的最低成本对应平衡约束的拉格朗日乘子,就是实时电价(\lambda_{RT,s,t}),这个乘子又回到上层目标函数中。
2.3 耦合变量的经济学含义
把变量归纳一下:
| 变量 | 含义 | 所属层级 |
|---|---|---|
| (P_{DA,t}) | 日前市场竞标注入功率 | 上层决策变量 |
| (P_{RT,s,t}) | 场景下的实时实际注入功率 | 上层决策变量(第二阶段) |
| (SOC_{t,s}) | 场景下的电池荷电状态 | 上层状态变量 |
| (P_g) | 常规机组实时出力 | 下层决策变量 |
| (\lambda_{RT,s,t}) | 实时市场出清电价 | 下层对偶变量,被上层目标引用 |
这里有个容易被忽略的经济学细节:如果(P_{RT,s,t}=P_{DA,t}),也就是实际执行和日前申报完全一致,实时结算项的(\lambda_{RT}(P_{RT}-P_{DA}))就会消失,聚合商所有电量都按日前价结算。只要存在偏差,实时电价就会参与边际结算。这个结算结构正是两阶段博弈的驱动力——聚合商既要通过日前申报去影响实时电价,又要根据实时电价的反馈来微调自身功率。
3. 求解的关键:KKT条件、互补松弛与大M线性化
3.1 为什么不直接“两层嵌套求解”
很多人拿到双层模型的第一反应是:外层用遗传算法或粒子群,内层用线性规划,套两层循环迭代不就行了?
这个思路想问题不大,做起来很痛苦。内层每评估一次目标就要调用一次求解器,外层种群规模一上来,相当于成千上万次调用,光通信开销就够喝一壶。更麻烦的是,外层启发式算法对变量尺度非常敏感,SOC、功率、电价不同量纲混在一起,很容易搜到一个看似不错实则不满足KKT条件的解。对学术论文来说,这种“数值解”说服力不够;对工程落地来说,运行时间也不可接受。
正确的做法是把下层问题的最优性条件写出来,作为一组约束并入上层模型,将双层问题转化为单层带均衡约束的数学规划(MPEC),再用商业求解器一次求解。下层模型只要是凸优化,KKT条件就是其最优解的充要条件,这个转换是严格的。
3.2 下层最优性的KKT刻画
考虑到下层模型包含机组出力上下限,写出拉格朗日函数:
[ \mathcal{L} = \sum_g (a_g P_g^2 + b_g P_g) + \lambda (D - P_{RT} + P_{DA} - \sum_g P_g) + \sum_g \mu_g^{\max}(P_g - P_g^{\max}) - \sum_g \mu_g^{\min} P_g ]
KKT条件包含四组:
一是平稳性条件:
[ 2a_g P_g + b_g - \lambda + \mu_g^{\max} - \mu_g^{\min} = 0 ]
二是原始可行性:
[ \sum_g P_g = D - P_{RT} + P_{DA},\quad 0 \le P_g \le P_g^{\max} ]
三是对偶可行性:
[ \mu_g^{\max} \ge 0, \quad \mu_g^{\min} \ge 0 ]
四是互补松弛条件:
[ \mu_g^{\max} (P_g - P_g^{\max}) = 0, \quad \mu_g^{\min} P_g = 0 ]
前三组都是线性约束,可以直接放进上层。麻烦的是第四组,两个变量的乘积等于零,天然是非线性非凸的,不能直接交给线性规划或者二次规划求解器。
3.3 互补松弛条件转整数线性约束
处理互补松弛最工程化的手段是大M法。以(\mu_g^{\max} (P_g - P_g^{\max}) = 0)为例,引入二进制变量(z_g^{\max}),定义两个方向互斥的约束:
[ \mu_g^{\max} \le M z_g^{\max} ]
[ P_g^{\max} - P_g \le M (1 - z_g^{\max}) ]
当(z_g^{\max}=1)时,第一条约束让对偶变量可以取正值,但第二条约束强制(P_g)贴近上限;当(z_g^{\max}=0)时,第二条约束自由,但第一条约束把对偶变量压到0。这样就完美表达了“要么出力顶到上限,要么该上限约束的乘子为零”的物理含义。另一组(\mu_g^{\min} P_g=0)同理,引入(z_g^{\min})。
大M法引入二进制变量后,整个MPEC变成一个MIQP(混合整数二次规划),可以用Gurobi或CPLEX这类求解器做全局求解。这也是为什么我在代码实现里坚持用MIQP路线,而不是随便丢给fmincon。
3.4 下层无边界时的简化:边际电价解析式
如果你的场景里机组容量足够充裕,下限取0、上限短期不活跃,下层KKT可以大幅简化。平稳性条件直接给出:
[ P_g = \frac{\lambda - b_g}{2a_g} ]
代入功率平衡方程,可以得到实时电价的显式表达式:
[ \lambda_{RT,s,t} = \frac{D_{s,t} - (P_{RT,s,t} - P_{DA,t}) + \sum_g \frac{b_g}{2a_g}}{\sum_g \frac{1}{2a_g}} ]
这个公式特别适合做代码快速验证:先忽略机组边界,把(\lambda_{RT,s,t})写成(P_{RT,s,t})和(P_{DA,t})的线性函数,直接代入上层目标,就能把一个双层问题变成一个带二次目标的单层优化。代码量大幅下降,且求解速度快到可以反复调参。作为完整KTT方案的一个对标参考,是非常有价值的。
4. MATLAB代码实现:从模型到可运行工程
4.1 环境准备:版本、YALMIP与Gurobi
先说环境。我的主跑环境是MATLAB 2026b,Windows 11,配YALMIP r2025稳定版和Gurobi 11。学术版许可证申请很快,Gurobi装好后在MATLAB里执行gurobi_setup即可。YALMIP不是求解器,它只是建模语言,负责把优化问题翻译成Gurobi能识别的标准形式。没有Gurobi的时候,也可以用CPLEX或者MATLAB自带的intlinprog,但MIQP问题用intlinprog支持有限,强烈建议还是装Gurobi。
YALMIP安装没有坑,下载后加进MATLAB路径就行。唯一要注意的是和MATLAB版本兼容性,2026b跑YALMIP r2025完全正常,但如果你用的是老版本MATLAB,记得选对应时间的YALMIP快照,否则可能遇到mpower这类基础函数识别错误。
4.2 参数设计与场景生成
参数设计是整个仿真合理性的大前提。我常用的基准参数如下:
- 聚合规模:(N_{EV}=1000)辆;
- 单车电池容量:60 kWh,聚合容量58860 kWh,考虑充放电效率95%;
- 单桩最大充放电功率:7 kW;
- SOC运行区间:0.2~0.9,初始SOC取0.5;
- 充放电互斥:同一时刻只能充电或放电;
- 成本系数:电池退化系数取0.02元/kWh²,作为二次惩罚项;
- 常规机组:3台,成本系数分别为(a=[0.002,0.003,0.001]),(b=[20,25,18]),上限([200,250,300]) MW。
场景生成是双层随机规划里另一个重要环节。我这里用5个代表性场景近似实时电价的概率分布:先取一条基准负荷曲线(D_{base}(t)),再在每个时段叠加正态随机扰动得到(D_s(t)),概率(\pi_s)均匀取(1/5)。场景数不需要太多,重点检验方法可行性。实际工程项目中如果要用高精度结果,再上蒙特卡洛抽几百个场景做缩减,这里先跑通逻辑。
4.3 核心代码:变量声明、KKT构建与求解
先说简化版代码(忽略机组上下限)。这个版本通过3.4节的解析公式把下层电价直接写出来,求解速度最快,适合调试逻辑。
% 参数设置 N_H = 24; % 时段数 N_S = 5; % 实时场景数 N_G = 3; % 机组数 dt = 1; % 小时 a_gen = [0.002, 0.003, 0.001]'; b_gen = [20, 25, 18]'; pg_max = [200, 250, 300]'; % 无边界情况下电价表达式系数 A0 = sum(1 ./ (2 * a_gen)); B0 = sum(b_gen ./ (2 * a_gen)); % 场景负荷生成 D_base = 300 + 100 * sin((1:N_H) / 24 * 2 * pi)'; D_s = repmat(D_base, 1, N_S) + randn(N_H, N_S) * 30; % 决策变量 P_DA = sdpvar(N_H, 1, 'full'); % 日前竞标功率 P_RT = sdpvar(N_H, N_S, 'full'); % 实时功率,场景相关 SOC = sdpvar(N_H + 1, N_S, 'full'); % 荷电状态 % 实时电价用解析式表达 L_net = D_s - (P_RT - repmat(P_DA, 1, N_S)); % 净负荷 lam_RT = (L_net + B0) / A0; % 边际电价 % 上层目标 profit_da = sum(lambda_DA .* P_DA); profit_rt = sum(sum(pi_s .* lam_RT .* (P_RT - repmat(P_DA, 1, N_S)))); cost_battery = alpha * sum(sum(P_RT .^ 2)); Objective = -(profit_da + profit_rt - cost_battery); % 上层约束 Constraints = []; for s = 1:N_S Constraints = [Constraints, SOC(1, s) == 0.5]; for t = 1:N_H Constraints = [Constraints, SOC(t + 1, s) == SOC(t, s) - P_RT(t, s) * dt / E_cap]; Constraints = [Constraints, SOC_min <= SOC(t + 1, s) <= SOC_max]; Constraints = [Constraints, -P_max <= P_RT(t, s) <= P_max]; end end Constraints = [Constraints, -P_max <= P_DA <= P_max]; % 求解 ops = sdpsettings('solver', 'gurobi', 'verbose', 1); optimize(Constraints, Objective, ops);这段代码的核心逻辑是:下层出清价格不再需要求解器,而是通过KKT平稳性直接写成上层变量的线性函数。这样上层变成一个带二次目标、线性约束的凸二次规划,Gurobi一次就能全局收敛。
如果你的模型需要完整保留机组上限约束,就要上大M法。我在实际工程中用的完整版代码长这样,关键片段如下:
% 下层变量 PG = sdpvar(N_G, N_H, N_S, 'full'); mu_max = sdpvar(N_G, N_H, N_S, 'full'); mu_min = sdpvar(N_G, N_H, N_S, 'full'); z_max = binvar(N_G, N_H, N_S, 'full'); z_min = binvar(N_G, N_H, N_S, 'full'); lam_RT_var = sdpvar(N_H, N_S, 'full'); M = 1e4; KKT_cons = []; for t = 1:N_H for s = 1:N_S L_net = D_s(t, s) - (P_RT(t, s) - P_DA(t)); % 平稳性 KKT_cons = [KKT_cons, 2 * a_gen .* PG(:, t, s) + b_gen - lam_RT_var(t, s) ... + mu_max(:, t, s) - mu_min(:, t, s) == 0]; % 原始可行性 KKT_cons = [KKT_cons, sum(PG(:, t, s)) == L_net]; KKT_cons = [KKT_cons, PG(:, t, s) >= 0, PG(:, t, s) <= pg_max]; % 大M处理互补松弛 KKT_cons = [KKT_cons, mu_max(:, t, s) >= 0, mu_max(:, t, s) <= M * z_max(:, t, s)]; KKT_cons = [KKT_cons, pg_max - PG(:, t, s) >= 0, pg_max - PG(:, t, s) <= M * (1 - z_max(:, t, s))]; KKT_cons = [KKT_cons, mu_min(:, t, s) >= 0, mu_min(:, t, s) <= M * z_min(:, t, s)]; KKT_cons = [KKT_cons, PG(:, t, s) >= 0, PG(:, t, s) <= M * (1 - z_min(:, t, s))]; end end这里必须提醒一句:大M法的M值不是随便给的。M太小会误伤可行域,M太大则会让求解器在分支定界中产生严重的数值病态。我的经验是先用解析法算出对偶变量的大致量级,再取比这个量级高两到三个数量级的数值,通常M在1e3到1e5之间。比如电价量级是几百元/MWh,M取1e4左右就够用。
4.4 结果提取与可视化
求解完成后,除了看目标函数值,更要关注三条曲线:日前竞标功率曲线、各场景下的实时功率曲线、实时电价曲线。
P_DA_opt = value(P_DA); P_RT_opt = value(P_RT); lam_RT_opt = value(lam_RT_var); % 完整版用对偶变量取值 figure; bar(1:N_H, P_DA_opt); hold on; plot(1:N_H, mean(P_RT_opt, 2), '-o', 'LineWidth', 1.5); plot(1:N_H, mean(lam_RT_opt, 2), '--', 'LineWidth', 1.5); legend('日前竞标功率', '期望实时功率', '期望实时电价'); xlabel('时段');看结果时我一般会先确认一个基本原则:当实时电价预期高时,聚合商的实时放电量应该上升;当实时价格低时,实时充电量应该上升,形成低买高卖。如果优化结果里出现了“高电价时段反而充电”这种违反套利直觉的结果,多半是SOC边界约束或者出行约束写错了,先去查约束矩阵。
5. 调试经验:大M陷阱、KKT误用与结果验证
5.1 大M取值是玄学也不是玄学
大M值在MPEC求解里是经典坑,我在这上面消耗过整整两天。第一次完整版模型跑出来的结果特别离谱:日前竞标量全天都是上限值,实时电价也被顶得奇高。后来单步检查发现是M取太大,导致分支定界中很多节点因为数值误差没有正确关闭互补约束,求解器被“骗”进了一个伪最优。
调试方法很简单:把M逐步降到1e2、1e3,观察目标值和变量是否有跳跃;如果结果在某个区间稳定下来,说明取值可靠。稳定性验证比单点最优值重要得多。另一个辅助手段是求解后检查互补松弛残差:
comp_res_max = max(max(value(mu_max) .* (value(PG) - repmat(pg_max,1,N_H,N_S)))); comp_res_min = max(max(value(mu_min) .* value(PG)));如果残差量级在1e-4以下,基本可以认为互补条件被正确满足。如果残差高达个位数,那M值或容差设置一定出了问题。
5.2 YALMIP kkt函数使用边界
YALMIP内置的kkt函数确实可以自动生成下层KKT系统,用法很简洁:
[kkt_sys, details] = kkt(lower_cons, lower_obj, upper_vars);但我在实际项目里用它的次数越来越少。原因是kkt函数生成的结果中,互补松弛条件往往以非线性乘积形式出现,对求解器类型限制较大。一旦下层约束稍微复杂一些,比如加了机组爬坡约束或者线路潮流约束,kkt函数生成的中间表达式会膨胀得厉害,MATLAB内存占用也跟着暴涨。
它更适合用来快速验证小规模模型,或者当作检查手动KKT推导是否出错的对拍工具。真正做论文仿真或工程落地,建议还是手动写KKT并配大M线性化。这样每个乘子的物理含义都清清楚楚,出了问题也方便定位。
5.3 双层结果的三层验证法
跑出结果之后别急着画图,先做三层验证。第一层是最优性验证:用一个固定的日前竞标变量,把下层市场出清单独解一遍,得到的实时电价必须和双层求解器返回的对偶变量值一致。这一步能查出耦合变量方向是否写反。
第二层是全局性验证:对简化版模型(无机组上下限)可以直接枚举多组P_DA初值,用非线性优化器fmincon求局部最优,再取其中最好的一组和Gurobi的MIQP结果对比。两者基本一致,说明大M线性化没有引入额外次优性。
第三层是经济一致性验证:检查优化后的收益是否比“完全不参与实时调整”的基准收益更高。基准策略是日前申报等于实际功率,实时偏差为零。如果双层模型连这种最简单的基准策略都跑不赢,算法实现基本可以认定有bug。
5.4 常见报错与处理
我在跑这套代码时最常碰到的报错有三个。第一个是“No appropriate method for sdpvar * sdpvar”,这种通常出现在把两个sdpvar直接相乘,导致产生非凸二次项的时候。需要检查上层目标里是不是误把定价变量和功率变量做了乘积但没有展开成标准形式。
第二个是“Gurobi: quadratic constraints not supported”,这说明你把某个二次表达式放进了约束而不是目标函数。KKT里平稳性条件是线性等式,功率平衡是线性等式,原则上不会有二次约束;如果出现这个报错,多半是SOC递推里把功率和状态变量乘在一起了,要回头检查约束构建。
第三个是求解器报告整数变量过多导致内存不足。这个可以通过减少场景数或机组数来缓解,也可以尝试把互补约束中对偶变量上限对应的M设为不同值,减少无谓的二进制变量分支。工程上还有一种做法是先用连续松弛求解一轮,得到变量量级后再固定一部分互不影响的对偶变量,但我一般不建议这样做,容易丢失全局最优。
6. 一点个人经验
这套双层优化代码从模型搭建到调试通过,前前后后花了两周多。回头总结,最耗时间的其实不是数学推导,而是对“双层博弈”直觉的建立——你得从内心相信自己的投标行为会影响市场价格,然后才能接受KKT转换这套复杂操作。如果你刚接触这个方向,我强烈建议先用3.4节的边际电价解析式版本跑通整个流程,再逐步引入机组限幅和大M法。先看得见结果,再追求模型保真度,这样上手最快,也最容易排查问题。代码里每一步都留好注释,求解后务必检查互补松弛残差,这两件事做好了,你的双层优化竞标策略基本就不会翻车。