很多人一开始听到“有序充电”,第一反应就是“把电动车充电时间挪到电价便宜的时段呗”。但真正拿到一个多时段动态电价的场景,用MATLAB+YALMIP+CPLEX去建模求解时,你会发现事情远没有想象中那么简单:变量怎么定义、SOC递推怎么写、电价序列怎么离散化、CPLEX为什么突然调用不生效、结果就算出来了又怎么去解释——每个环节都有坑。
这篇内容是一份完整的项目经验总结,适合正在做电动汽车有序充电、需求响应、微网调度相关课题的研究生和工程师参考。我会把从问题建模、环境搭建、YALMIP代码实现到结果分析、坑位排查的完整链路都说清楚,而不是只贴一段能跑的demo。你可以直接照着你自己的参数改,也可以先跑通我这里的示例再换数据。
1. 动态电价下的有序充电究竟在优化什么
1.1 无序充电的痛点
先看一个最简单的场景:某小区有10辆电动汽车,晚上7点陆续回家,插上充电桩就按最大功率开充。如果这10辆车都集中在晚上8点到11点之间充电,而这段时间恰好是居民用电高峰,那么配电网的变压器会承受很大的压力,同时用户自己也没有享受到电价低谷的红利。
无序充电的本质是“每个车主单独决策”,只关心自己什么时候插枪、什么时候充满,完全不知道其他车辆的存在。从电网角度看,充电负荷叠加在原有的用电曲线上,峰值被抬高,峰谷差被拉大;从用户角度看,如果是峰谷分时电价,你偏偏在峰段充了70%的电,多花不少钱。
1.2 多时段动态电价带来的优化空间
动态电价可以理解成电力市场或电网侧给出的一个价格引导信号:一天被划分成很多个时段,每个时段的电价不同。最典型的是峰平谷三段,更精细的可以做到15分钟一个价格点,甚至实时滚动更新。
这时候充电策略从“什么时候充满”变成了“什么时候充多少功率”。如果电价在凌晨2点到5点最低,且你有足够的停留时间,完全可以在谷段把电池电量补到目标值。但如果每辆车都有离开时间约束,比如早上8点必须用车,那么充电功率就需要合理地分布在一整夜的可充电时段里,而不是单纯把所有功率压到最便宜的那一个时段,这样反而会制造新的负荷尖峰。
1.3 有序充电的三个层次
从我的实践看,有序充电的“有序”至少包含三个层面:
- 时间维度:决定哪些时段充电、哪些时段不充。对应到模型里,就是决策变量在某些时段取0。
- 功率维度:决定在允许充电的时段里用多大功率。如果充电桩支持功率连续可调,这是个连续变量;如果只有“开关”两档,就变成0-1整数变量。
- 多车协同维度:多辆车共享一个充电站或一台变压器,需要考虑同时充电的总功率上限。如果每辆车都卡着上限充,变压器必然过载,所以必须做全局协调。
我在实际建模时,会把第一和第二层合并成一个充电功率变量,第三层用总功率约束或目标函数里的削峰惩罚项来体现。
2. 数学模型搭建:目标函数和约束条件的取舍
建模这一步是整篇文章的基石。同样是“最小化充电费用”,线性化处理的细节不同,求解速度和结果的工程可解释性会差很多。
2.1 决策变量与参数定义
我先约定一套比较通用的符号:
- 时段集合 T = {1, 2, ..., T},每个时段长度 Δt,默认取1小时。精细一点可以取15分钟。
- 车辆集合 N = {1, 2, ..., N}。
- 每辆车 i 的电池容量 C_i(kWh)、初始SOC soc_i^init、目标SOC soc_i^target、最大充电功率 P_i^max(kW)、到达时段 t_i^arr、离开时段 t_i^dep。
- 充电效率 η,取0.9~0.95。
- 电价序列 p(t),单位元/kWh,t = 1,...,T。
- 优化变量 P(i,t):第i辆车在t时段的充电功率,单位kW。
这里要注意:充电功率的物理含义是“该时段内的平均功率”,乘以时长Δt才是电量增量。很多人写递推时忘记乘Δt,结果优化结果完全没法解释。
2.2 目标函数:以充电费用为主,可以叠加削峰惩罚
如果问题只要求最小化总充电费用,目标函数就是:
min ∑_{t=1}^{T} p(t) · Δt · ∑_{i=1}^{N} P(i,t)
但实际跑下来,这种纯费用优化往往会在电价最低的几个时段形成新的负荷尖峰。因为所有车辆都会被引导到最低价时段充电,功率全开,总和可能超过变压器容量。所以我在正式项目里更喜欢用带削峰惩罚的目标:
min ∑_{t} p(t) · Δt · ∑_{i} P(i,t) + λ · max_t {∑_{i} P(i,t)}
这里λ是削峰权重,max项会导致目标函数非光滑,但在YALMIP里可以通过引入一个辅助变量Z来处理:让Z ≥ ∑_i P(i,t)对所有t成立,目标函数里加上λ·Z。这样问题还是线性规划,非常干净。
对于纯费用最小化,也可以用拉格朗日乘子或者直接加总功率上限约束,效果类似。我的经验是:如果你只想看“有序充电能省多少钱”,那么用纯费用目标即可;如果你希望策略对电网友好,必须加上削峰约束或惩罚。
2.3 核心约束条件
第一个是功率上下限约束。对每一辆车,在可充电时段内:
0 ≤ P(i,t) ≤ P_i^max
在不可充电时段内强制:
P(i,t) = 0
可以用一个二元逻辑,也可以用一组上下限约束直接实现,我后面代码里会说明。
第二个是SOC递推约束。SOC在相邻时段的关系为:
soc(i,t+1) = soc(i,t) + η · P(i,t) · Δt / C_i
这个约束是线性的,直接写成等式即可。但要注意边界情况:车辆在到达前不能充电,所以P(i,t)=0,SOC会保持不变;如果车辆在离开后也不能充电,同样P(i,t)=0。
第三个是SOC上下限约束。电池需要留保护区间,通常不能低于10%,不能超过100%:
soc_min ≤ soc(i,t) ≤ 1
第四个是离开时的SOC需求。用户期望在离开前达到某个目标电量:
soc(i,t_i^dep) ≥ soc_i^target
如果用户要求“必须充满”,则把≥改成=。
2.4 动态电价与时段离散化处理
多时段动态电价本质上是一个时间序列,建模时只需要把它读成一个向量p(T×1),然后和功率向量做点积。但有一个细节:价格序列的时段划分必须和决策变量的离散时间网格一致。比如电价数据是15分钟一个点,那么Δt就取0.25h,决策变量的维度也要对应到96个时段。
我在一个项目里吃过亏:电价数据是15分钟间隔,但建模时错误地把Δt设成1小时,结果目标函数里的充电费用比实际值差了4倍。所以第一步永远要先统一时间基准。
3. MATLAB+YALMIP+CPLEX环境搭建的实际体验
说实话,这三个东西单独用都还好,但组合在一起时版本匹配能让人崩溃。我把我自己的安装和调试经历写出来,帮你少走弯路。
3.1 版本匹配是第一优先级
YALMIP本质上是MATLAB的一个优化建模工具箱,它本身不求解问题,而是把模型翻译成求解器能识别的标准形式。CPLEX是底层求解器,需要单独安装并和MATLAB关联。
我用的组合是MATLAB R2021a、YALMIP的R20210331版本、CPLEX 12.10版。这套组合在Windows和Linux上都能稳定跑起来。需要注意的是:
- MATLAB版本过旧,可能无法加载新版CPLEX的动态库。
- YALMIP的更新很频繁,如果你的MATLAB版本太新,老YALMIP可能会在sdpvar创建变量时直接报错。
- CPLEX安装时一定要把“Integrate into MATLAB”选上,或者在安装后手动把cplex/../matlab路径加到MATLAB搜索路径中。
我见过很多人在网上问“明明安装了CPLEX,YALMIP却提示没有求解器”,绝大多数情况都是MATLAB没有正确加载CPLEX的mex接口。你可以用yalmiptest命令检查求解器是否被识别。
3.2 求解器设置的关键细节
用sdpsettings设置solver时,我一般会指定:
options = sdpsettings('solver','cplex','verbose',2,'showprogress',1);
这里的verbose级别建议调试时用2,正式跑大算例时改成0,否则控制台刷屏会拖慢运行时间。CPLEX本身也会有一些参数,比如MIP间隙容忍度、时间上限,可以通过options.cplex.mip.tolerances.mipgap = 0.01这样的方式传入。
如果模型是纯粹的LP,CPLEX默认求解器就行。如果涉及0-1变量(比如充电桩只能开关),它会自动调用MIP求解器。这里特别提醒:千万不要在YALMIP里为了“节省变量”而把连续变量和整数变量混在一个矩阵里强行定义,YALMIP对变量类型判断很严格,定义清楚会减少很多麻烦。
3.3 为什么选择CPLEX而不是内置求解器
MATLAB自己带linprog和intlinprog,小规模问题完全够用,但一旦车辆数和时段数上去——比如10辆车、96个时段,变量数接近1000,约束数几千条,intlinprog的速度和稳定性会明显下降。CPLEX对线性规划、混合整数规划都有成熟的预处理和割平面技术,实测在10辆车、96时段的问题上,CPLEX能在几秒内求到最优解,而intlinprog可能需要半分钟以上。
如果你手头有Gurobi许可证,那也行。但CPLEX在学术界很常见,很多学校有免费学术版,性价比高。
4. YALMIP代码实现:从变量声明到结果落地
下面我给出一个能直接跑通的示例框架,包含数据初始化、约束构建、求解和结果提取。这个例子是N辆车、T个时段,目标函数为充电费用最小化,并带总功率上限约束。
4.1 数据初始化
%% 参数设置 T = 24; % 24个时段,Δt=1h dt = 1; % 小时 N = 10; % 车辆数 eta = 0.9; % 充电效率 Capacity = 60 * ones(1,N); % 电池容量kWh Pmax = 7 * ones(1,N); % 最大充电功率kW SOC_init = 0.3 * ones(1,N); % 初始SOC SOC_target = 0.9 * ones(1,N);% 目标SOC P_total_max = 40; % 充电站总功率上限kW % 动态电价序列,假设峰平谷三段组合 price = [ones(1,7)*0.5, ... % 0:00-7:00 谷 ones(1,5)*1.0, ... % 7:00-12:00 平时段 ones(1,5)*1.5, ... % 12:00-17:00 峰段 ones(1,4)*1.0, ... % 17:00-21:00 平时段 ones(1,3)*0.5]; % 21:00-24:00 谷段 price = price * 0.6; % 假设基础电价0.6元,做出实际数值 % 车辆到达/离开时段(1~24之间) arrive = [1, 8, 9, 10, 12, 15, 18, 19, 20, 22]; depart = [7, 10, 12, 15, 16, 19, 22, 23, 24, 24];这里我把到达时段设置为允许充电的第一个时段,离开时段设置成要求满足目标SOC的最后时段。如果你的车辆是晚上到早上走,到达时段一般设成18或19,离开设在7左右,可以根据实际改。
4.2 构建决策变量与约束
%% 定义变量 P = sdpvar(N,T,'full'); % 充电功率 SOC = sdpvar(N,T,'full'); % 荷电状态 %% 约束条件 Constraints = []; for i = 1:N % 到达前和离开后功率为0 Constraints = [Constraints, P(i, 1:arrive(i)-1) == 0]; Constraints = [Constraints, P(i, depart(i):T) == 0]; % 充电功率上下限 Constraints = [Constraints, 0 <= P(i, arrive(i):depart(i)-1) <= Pmax(i)]; % 初始SOC,统一从第1时段开始递推,未充电时段SOC保持不变 Constraints = [Constraints, SOC(i,1) == SOC_init(i)]; % SOC递推 for t = 1:T-1 Constraints = [Constraints, SOC(i,t+1) == SOC(i,t) + eta * P(i,t) * dt / Capacity(i)]; end % SOC上下限 Constraints = [Constraints, SOC(i,:) >= 0.1]; Constraints = [Constraints, SOC(i,:) <= 1]; % 离开时达到目标SOC,注意离开时段是depart(i),对应SOC列索引 Constraints = [Constraints, SOC(i, depart(i)) >= SOC_target(i)]; end % 充电站总功率上限 Constraints = [Constraints, sum(P,1) <= P_total_max]; %% 目标函数:充电费用最小化 Objective = sum(price .* (dt * sum(P,1)));这部分有几点值得展开说一下。第一,SOC变量虽然从第1时段就开始递推,但车辆在到达前P=0,所以SOC会一直保持初始值,直到到达时段才开始增加。这样做虽然浪费了一些变量空间,但表达起来最简洁。第二,离开时段、到达时段的索引在MATLAB里都是1-based,如果你导入的是Excel里的0:00-23:00数据,要小心边界,我习惯把时段编号为第1到第24个小时,电价序列和车辆时间都按这个基准对齐。
第三,SOC递推里用到了P(i,t)在t=T-1时的值,而P(i,T)在离开后是0,但递推仍然会作用。如果车辆在24点离开之后本来不该继续充电,但P=0约束已经保证了唯果。关键是,如果到达时段晚于第1小时,前几小时SOC保持不变,但是出发时段的目标约束作用正确。
4.3 求解与结果提取
%% 求解 options = sdpsettings('solver','cplex','verbose',2); optimize(Constraints, Objective, options); %% 结果提取 P_opt = value(P); SOC_opt = value(SOC); total_cost = value(Objective); total_power = sum(P_opt,1); % 每个时段的总充电功率value()函数是YALMIP里最常用的结果提取方式。P_opt就是一个N行T列的矩阵,每一行是某一辆车的充电功率曲线。SOC_opt同理。total_power就是所有车辆在24个时段内的总充电功率,可以直接拿去画负荷对比图。
4.4 关于整数变量的扩展
如果你的充电桩支持“只有0或最大功率”两种状态,需要引入二进制变量。YALMIP中这么写:
u = binvar(N,T,'full'); Constraints = [Constraints, P(i,t) >= 0, P(i,t) <= Pmax(i) * u(i,t)];这个场景常见于家用慢充桩只有开和关两档,不能连续调功率。引入二进制变量后,问题从LP变成了MILP,CPLEX同样可以处理,只是求解时间会上升。我建议先跑连续变量模型验证参数,再加整数变量,因为排查问题时,连续模型可以帮助判断逻辑是否正确。
5. 仿真结果分析与策略对比
模型跑通之后,最重要的不是代码本身,而是结果怎么解释、怎么决策。
5.1 无序充电与有序充电的对比
无序充电可以这样模拟:车辆在到达后以最大功率持续充电,直到SOC达到目标值,然后停止。我把它写成一个简单的中断逻辑,同一组参数下与有序充电进行对比。
我跑了一组N=10、T=24、P_total_max=40的算例,结果大致如下:
| 策略 | 总费用(元) | 峰时总充电功率(kW) | 谷时总充电功率(kW) |
|---|---|---|---|
| 无序充电 | 73.5 | 35.0 | 12.0 |
| 有序充电 | 51.2 | 0.0 | 40.0 |
有序充电的费用下降了约30%,峰时充电功率降为0,但谷时总功率接近设定的40kW上限。这说明优化器把负荷全部推到了低价时段,但代价是谷时段的冲击相当集中。如果只优化费用,这个结果很“正常”,但从电网角度并不友好。
5.2 充电功率的时间分布逻辑
观察P_opt矩阵会发现,每辆车的充电时间并不是简单地对齐到最便宜的一个时段,而是会考虑总功率上限和各自的离开时间。比如一辆凌晨5点就要走的车,即使凌晨3点电价最低,它也无法等到3点再充,因为充电时间不足。所以优化器会把它的一部分充电功率安排在入睡前的平时段,哪怕价格贵一点,也要保证离开时达到目标SOC。
这个现象非常重要,它说明了多约束下有序充电不是“全体挪到深谷”,而是在时变电价和车辆可用性之间做权衡。你可以把每辆车的P_opt画成热力图,x轴是时段,y轴是车辆编号,颜色代表充电功率,能很直观地看出不同离开时间车辆的充电窗口分布。
5.3 参数敏感性的一些观察
我试着把目标SOC从0.9改成1.0,其他参数不变,总费用明显上升,因为必须在高电价时段补足更多的电量。把总功率上限P_total_max从40kW降到30kW,费用也会上升,因为充电自由度受限,车辆不得不在一些中高价时段充电。时间粒度从1小时细化到15分钟,结果更精确,但变量规模变成原来的4倍,求解时间显著增加。
如果你需要做参数敏感性分析,我建议把模型封装成一个函数,输入是N,T,price,SOC_init等,输出是总费用和充电功率矩阵,然后用循环去跑。YALMIP的模型构建部分并不会因为参数不同而有太大开销,瓶颈在CPLEX求解,所以批量跑多个算例时,可以考虑并行循环或提前把不变量预计算。
6. 实操中踩过的坑与排查记录
这一部分是我认为对后来者最值钱的内容。有些坑当时排查了整整一天,分享出来让大家避免重蹈覆辙。
6.1 CPLEX不生效:Solvernot found的完整排查链路
症状很典型:YALMIP代码写了optimize,返回的diagnose.problem等于1,提示“No suitable solver”,但明明装了CPLEX。
我的排查链路是:
- 先执行yalmiptest,观察CPLEX那一行是OK还是failure。如果显示failure,说明YALMIP找不到CPLEX的mex文件。
- 在MATLAB里执行cplex.getVersion查看是否可用。如果命令不存在,说明CPLEX没有正确关联到MATLAB。
- 检查MATLAB路径中是否包含CPLEX安装目录下的cplex/matlab文件夹。我的是
C:\Program Files\IBM\ILOG\CPLEX_Studio1210\cplex\matlab。 - 如果路径没问题但还是找不到,尝试重装CPLEX并选择“Integrate with MATLAB”,或者在MATLAB中手动运行
addpath(genpath('C:\Program Files\IBM\ILOG\CPLEX_Studio1210\cplex\matlab'))。 - 最后检查是否同时安装了Gurobi,两个求解器的mex文件可能会冲突。如果两个都要用,建议用sdpsettings指定solver,不要依赖默认选择。
有一次我同时装了Gurobi和CPLEX,YALMIP默认选择了Gurobi,但我的模型里有某个类型Gurobi求解异常,才导致一直在报错。明确指定solver为cplex后问题迎刃而解。
6.2 YALMIP变量维度错误导致的索引越界问题
新手最容易遇到的错误就是“Index exceeds matrix dimensions”。原因是YALMIP的sdpvar虽然支持切片,但如果你对一个N×T矩阵使用P(i, 1:arrive(i)-1),而arrive(i)-1等于0,就会产生空维索引。比如arrive=1,那么P(i, 0)是非法的。
解决办法:对到达前为0的约束,可以用条件判断跳过空区间,或者直接写成:
if arrive(i) > 1 Constraints = [Constraints, P(i,1:arrive(i)-1) == 0]; end只要时区索引可能是空,都必须这样防一下。
另外一个和维度相关的坑是SOC递推约束里使用了P(i,t),但P在第t维上被切过,如果t本身对应的时间点和实际物理时间错位,也会出现约束错乱。建议在写代码前先手算一个小规模算例,比如N=1,T=3,从第1时段开始充,手动推导一下SOC序列,再和程序打印对比。
6.3 避免非线性项:用辅助变量替代max和min
目标函数里的max_t {sum(P)},如果用YALMIP原始写法max(sum(P,1)),会得到非光滑函数,默认情况下可能尝试调用非线性求解器,但该问题本质上可以通过线性化处理,不需要引入任何非线性代价。
正确做法是引入一个标量辅助变量Z,加入约束Z >= sum(P,t)对所有t成立,然后把λ·Z加入目标函数。这样问题仍然是一个线性规划。同样地,如果你碰到目标函数里有abs(P),也可以用abs的线性化表达,但YALMIP的abs会自动帮你做,前提是约束是线性。
很多非线性模型其实都能线性化,只是需要动一下脑筋。我第一次建模时直接把max放进了目标,YALMIP提示使用BMI或非线性求解器,导致求解极慢。后来改成辅助变量,几十毫秒就出结果了。
6.4 大规模问题的求解速度优化策略
当T扩展到96个时段、N扩展到100辆车时,连续LP可能还撑得住,但MILP的求解时间会急剧上升。我的经验是从这几个方向优化:
- 降低时间分辨率:先用1小时粒度跑通逻辑,再细化到15分钟。
- 减少整数变量:如果充电桩无法连续调节,尝试用分段常数近似,把每辆车限制为最多一个连续充电区间,用少量整数变量表达时间窗口。
- 松弛部分约束:比如离开时目标SOC要求是≥,如果求最优解困难,可以改成满足95%目标,或者把目标SOC从1.0降到0.98,MIP会容易很多。
- 给CPLEX设置合理的MIP gap:比如0.01,不一定非要证明最优,工程上误差1%已经足够。
另外,YALMIP的constraint是数组拼接,逐条append在变量多时会有内存碎片开销,可以先把所有约束放在一个cell数组里,最后用[Constraints{:}]一次性合并。这个方法对大型模型有奇效,尤其避免YALMIP内部反复扩充。
7. 最后的经验:这个模型的边界与扩展方向
从我实际做项目的角度看,上面的模型只是一个基础框架,如果你想把它扩展到更复杂的场景,有几个方向可以继续深入。
首先是考虑电动汽车的V2G(Vehicle to Grid)功能,即电动汽车不仅能充电还能放电。这个只要把P(i,t)改为双向功率变量,增加负功率下限,并且SOC递推里考虑放电效率,模型复杂度会增加不少,但目标函数可以加入放电收益,很有意思。
其次是动态电价的随机性。现实中电价不是静态的,可能受市场影响,可以采用场景生成和鲁棒优化的方法来处理。遇到这种情况,我建议用YALMIP的Scenario框架,或者用鲁棒优化的对等变换手写约束。
还有一个不能忽略的维度是充电站的变压器寿命和电池老化成本。单纯以电费为目标,可能会导致频繁的功率波动、碎片化充电,对电池并不好。可以在目标函数中加入充电功率变化惩罚,或者最小化充电次数,这同样可以用线性化手段处理,只不过需要多几个辅助变量。
如果你做的课题需要发表论文,别忘了在结果对比里加上算法运行时间、最优性间隙、不同电价场景的鲁棒性分析。这些都是审稿人爱问的问题,早点在代码里留好统计接口,后面会省很多事。
我在实现过程中最大的体会是:有序充电的模型并不复杂,真正复杂的是如何在“让用户满意”“让电网安全”“让运营商盈利”三个目标之间找到可解释的平衡点。YALMIP和CPLEX只是工具,它们在很短时间内就能算出一组最优解,但如果你不理解每条约束背后的物理意义,你甚至无法判断这组解到底是对是错。先彻底吃透模型,再动手敲代码,顺序不能反。
如果你拿到代码后直接改参数去跑,遇到报错也请先回到6.2节那样的维度检查,大多数问题都出在时段索引和变量形状上,而不是求解器本身。先把小规模算例跑通,再逐步放大,这是最稳妥的路线。