做插电式混合动力车辆(PHEV)能量管理研究的人,应该都经历过这个阶段:模型建好了,MPC公式也推完了,打开Matlab编辑器却不知道第一行代码该写什么。标题里"ADMM""CVX""凸优化""MPC"这些词看着都眼熟,真正落到代码层,就是另一回事。这篇文章把我从建模、凸化到ADMM结合CVX跑通MPC的完整过程写出来,包括CVX安装和许可证、参数整定、报错排查,希望能帮有同样需求的人少走两三个月的弯路。
内容适合正在做新能源车辆能量管理策略研究的学生或工程师,也适合对凸优化在控制系统中落地感兴趣的读者。默认你有一点MPC基础,但即便你只听说过MPC,按下面的步骤走一遍,也能在Matlab里跑出自己的第一版程序。
1. 先把问题说清楚:PHEV的能源管理到底优化什么
1.1 从规则模式切换,到MPC功率分配
插电式混合动力车辆的整车控制,最核心的问题就是"发动机和电池谁来出力、各出多少力"。传统工程做法是规则策略:电量充足就优先用电,车速高了或急加速就让发动机介入,电池电量低了就强制发动机补电。这种规则策略的好处是直观、稳定、控制器容易实现,缺点是极限工况下不够优秀,因为规则的阈值标定依赖工程师的个人经验,换一个整车构型又得重新调,换一套工况也可能不是最优。
MPC的思路完全不同。它把未来一段时间的行驶需求用预测模型表达出来,在当前时刻解一个带约束的优化问题,得到未来若干步的最优控制序列,然后只执行第一步,下一步再滚动求解。用在PHEV上,就是每个控制周期重新做一次"发动机和电机功率分配"的决策。MPC天然能处理约束和前瞻,这也是它在能量管理领域这几年热度持续走高的原因。
1.2 MPC问题的数学形式
把问题写出来,是这样一个典型形式:状态量选SOC(电池荷电状态),控制量选发动机功率和电机功率。目标函数里,油耗和SOC偏离参考值的程度都要考虑。约束则包括:功率平衡约束,也就是整车需求功率等于发动机和电机功率之和;发动机和电机各自的功率上下限;电池SOC上下限;某些情况下还要考虑发动机启停限制。
公式长这样:
min ∑ [ fuel(P_eng_k) + β (SOC_k - SOC_ref_k)^2 ]
s.t. SOC_{k+1} = SOC_k - P_batt_k / Q_batt * Δt P_req_k = P_eng_k + P_mot_k P_eng_min ≤ P_eng_k ≤ P_eng_max P_mot_min ≤ P_mot_k ≤ P_mot_max SOC_min ≤ SOC_k ≤ SOC_max
这个形式中,SOC动态方程在工程上经常做线性化处理,因为电池内部参数随SOC变化,严格说是非线性函数,但做MPC只需要近似,误差由滚动优化来弥补。这也是后面能凸化的基础。需要注意的是,P_batt在放电时取正值、充电时取负值,符号约定一定要统一,我早期在这里吃过亏,代码里SOC在充放电切换时轨迹不平滑,排查半天发现是符号写反了。
1.3 原始问题为什么"非凸",以及怎么处理
这里要重点提醒一个关键点:实际发动机的油耗map(油耗率随转速和扭矩变化的关系)通常是严重非凸的,中间有各种等油耗区,直接用这个map做优化,整个问题就变成非凸的,常规非线性求解器既慢又不保证全局最优。所以工程和研究中都会做一个预处理:把油耗map用分段线性函数或二次函数做凸逼近,把非凸的NLP问题变成凸问题。
我最早直接用原map丢给fmincon,结果显示初始值稍微变一下解就跳来跳去,完全不适合在线控制。凸化之后,问题性质立刻变了,全局最优有保证,求解时间是稳定的,这对MPC这种滚动在线执行的框架极其重要。如果你的模型里还有发动机启停的0-1变量(也就是"发还是不发"的选择),同样可以做一个凸松弛处理,把整数变量放松成连续变量[0,1],代价是解可能带一点模糊性,工程上再加后处理修正,一般就能接受。
2. 求解链路为什么是CVX、凸优化与ADMM的组合
2.1 CVX工具箱:Matlab里写凸优化的"翻译器"
CVX是Matlab环境下的凸优化建模工具,核心价值在于你不需要自己写求解器,也不需要手动把模型化成特定求解器的输入格式,而是用接近数学表达式的语言直接把优化问题写出来。CVX会自动检测问题的凸性、选择内部solver、返回结果。PHEV的MPC问题约束多、变量维度高,手写解析求导或者把问题转成MPS格式都非常痛苦。用CVX能让模型可读性高很多,后期改权重、加约束基本就是改一个表达式的功夫。
安装和许可证方面,直接从官网下载对应Matlab架构的版本,解压后运行cvx_setup。学术许可证免费,用学校邮箱填表申请,CVX会把license文件发到邮箱,下载后放到CVX目录下重新执行cvx_setup,提示成功就是装好了。需要特别提醒一下,许可证要走正规渠道,不要用来路不明的文件,一旦混进恶意代码,Matlab脚本里能读出什么别的东西就不好说了,科研环境里这个风险没必要冒。安装过程中常见的版本兼容问题,我在第5部分会专门说。
2.2 ADMM到底解决什么问题
有了CVX,MPC凸问题理论上一个cvx_begin就能直接解,那为什么还要引入ADMM?答案在实时性。MPC是每个采样周期都要在线求解一次优化问题的,如果预测时域拉到20步,变量维度几十个,直接求解可能需要几秒钟才返回。仿真没问题,但你想做硬件在环或实车验证,这个时间就是致命的。
ADMM(交替方向乘子法)的核心思路是把一个大问题拆成几个易于求解的子问题,通过一个协作迭代机制把子问题的解协调到满足原问题的约束。用个生活化类比:你规划一周的任务,直接整体最优化很难一次算清楚,但你可以把"安排工作"和"安排休息"两个子问题分开排,来回调整几轮,很快就平衡好了。ADMM就是这个"来回调整"的数学化版本。
2.3 我的组合方式:外层ADMM,内层CVX
实现时我用的结构是这样的:外层是ADMM的迭代循环,负责把原始MPC问题分解成两个子问题——一个是以动力总成约束为主的功率分配子问题,一个是以SOC动态和状态边界为主的状态跟踪子问题。每次迭代中,这两个子问题都分别交给CVX去建模求解,然后更新对偶变量和辅助变量,计算原残差和对偶残差,判断是否收敛。
这个组合的好处很明显:每个子问题规模都很小,CVX求解速度极快;CVX自动处理约束和数值问题,鲁棒性好;ADMM的迭代层是纯Matlab代码,逻辑清晰,出了问题容易定位。这也是我强烈建议在Matlab里实现这套方法时采用的结构。
3. 从公式到代码:ADMM加CVX的MPC实现步骤
3.1 整体流程与仿真主循环
先看总流程。假设你手里已经有一个能跑固定工况的整车纵向模型,或者简化的一阶SOC模型,代码主循环大概是这样:
% 主仿真循环 Ts = 1; % 采样周期 1s Np = 15; % 预测时域 N_steps = length(drive_cycle); % 工况总步数 SOC = SOC_init; for k = 1:N_steps % 1. 预测未来需求功率序列(工况已知或由预测模块给出) P_req_pred = drive_cycle(k : k+Np-1) * P_base; % 2. 调用ADMM+CVX求解MPC问题 [P_eng_opt, P_mot_opt, info] = solve_mpc_admm(SOC, P_req_pred, Np); % 3. 只取第一步控制量作用到被控对象 P_eng = P_eng_opt(1); P_mot = P_mot_opt(1); % 4. 更新SOC和被控对象状态 SOC = SOC - P_mot / (V_ocv * Q_batt) * Ts; end主循环就四个步骤:预测、求解、执行、更新。跑起来不复杂,复杂的是里面那个求解函数。实际仿真中我习惯把每一步的SOC、P_eng、P_mot、求解耗时都存下来,最后统一画图分析,而不是边跑边plot,那样会严重拖慢仿真速度。
3.2 ADMM核心迭代代码
求解器函数solve_mpc_admm内部,是ADMM的迭代循环。这里给出一个骨架代码,去掉详细约束后用于展示迭代逻辑:
function [P_eng_seq, P_mot_seq, info] = solve_mpc_admm(SOC0, P_req, Np) rho = 1.0; % ADMM罚项 max_iter = 50; % 最大迭代次数 tol = 1e-4; % 收敛阈值 z = zeros(Np, 1); % 辅助变量 lambda = zeros(Np, 1); % 对偶乘子 for iter = 1:max_iter % --- 子问题1:功率分配,用CVX求解 --- u1 = solve_power_alloc(SOC0, P_req, z, rho, lambda); % --- 子问题2:SOC轨迹跟踪,用CVX求解 --- u2 = solve_soc_track(SOC0, P_req, u1, rho, lambda); % --- 辅助变量z更新 --- z_new = u2 + lambda / rho; % --- 对偶变量lambda更新 --- lambda = lambda + rho * (u1 - z_new); % --- 残差判断 --- r_residual = norm(u1 - z_new, inf); s_residual = norm(z_new - z, inf); z = z_new; if max(r_residual, s_residual) < tol break; end end end重点在于理解ADMM逻辑:u1和z交替优化,lambda是协调者,收敛时u1、z以及原问题约束都接近满足。残差要同时监控原残差和对偶残差,只看一个容易误判。实际仿真中我一般会把每次迭代的残差存下来,画出收敛曲线,这对判断罚项ρ是否合适非常有帮助。
3.3 CVX子问题建模示例
子问题用CVX写出来非常直观。以功率分配子问题为例,核心代码大致如下:
function P_eng = solve_power_alloc(SOC0, P_req, z, rho, lambda) Np = length(P_req); cvx_begin quiet variable P_eng(Np) variable P_mot(Np) P_batt = P_mot; % 简化:电机功率近似等于电池功率 minimize( sum(fuel_convex(P_eng)) ... + beta * sum_square(SOC_seq(SOC0, P_batt) - SOC_ref) ... + 0.5 * rho * sum_square(P_eng - z) ... + lambda' * (P_eng - z) ) subject to P_eng + P_mot == P_req; % 功率平衡 P_eng_min <= P_eng <= P_eng_max; P_mot_min <= P_mot <= P_mot_max; cvx_end end这段代码里,fuel_convex是你把发动机油耗map凸化后的函数,SOC_seq是根据SOC动态方程写的从SOC0开始递推的序列。CVX的sum_square、power这些函数对凸问题表达非常友好,写出来跟论文公式几乎一一对应。这也是我坚持用CVX而不是直接用solver接口的原因——可读性在科研代码里太重要了,半年前的代码连自己回头都要看半天,更别说别人接手。
3.4 关键参数怎么定
跑通这套代码后,最影响结果的参数大概是下面这几个:
| 参数 | 符号 | 常见范围 | 说明 |
|---|---|---|---|
| 预测时域 | Np | 10 ~ 30 | 越大前瞻性越好,但求解变慢,ADMM子问题规模也增大 |
| 采样周期 | Ts | 0.1 ~ 1 s | 能量管理一般不追求太高频,1s足够 |
| SOC权重 | beta | 100 ~ 1000 | 决定SOC跟踪的松紧程度,越大越倾向于保电量 |
| ADMM罚项 | rho | 0.1 ~ 10 | 太小收敛慢,太大容易振荡,从1附近开始调 |
| ADMM迭代上限 | max_iter | 30 ~ 100 | 在线控制建议限制迭代数,宁可次优也要按时输出 |
| 燃油与电量权重 | alpha | 看构型 | 反映油价和电价的相对关系,也是经济性调优的核心 |
调参顺序建议是:先把beta调到一个SOC不掉底的值,然后去调rho让ADMM尽快收敛,最后再微调油耗与SOC的综合权重。不要一开始就三个参数同时改,那样出了问题根本不知道是谁引起的。我见过不少同学上来就把rho改成100,结果残差振荡到天上去,还以为是算法写错了。
4. 仿真验证:怎么对比、怎么看结果
4.1 工况选择与对比方案
仿真想说明两件事:第一,ADMM加CVX的结果和直接CVX全局求解的结果偏差不大;第二,求解时间到底降了多少。工况我建议用WLTC或者市区工况这种有代表性的标准工况,别用自己编的随机工况,跑出来别人也没法复现。
对比方案也很简单:同一个MPC问题,方案A用CVX一次求解,也就是每个采样周期直接调用cvx_begin;方案B用ADMM加CVX迭代求解。两个方案都跑完整个工况,对比SOC曲线、累积油耗和平均求解时长。
4.2 结果怎么解读
我实际跑下来的结果大致是这样的趋势:
| 方案 | 平均单步求解耗时 | 油耗表现 | SOC终值 | 与基准最优性偏差 |
|---|---|---|---|---|
| 直接CVX求解 | 约1.2秒 | 基准 | 约0.31 | 基准 |
| ADMM+CVX | 约0.08秒 | 与基准接近,差值在0.5%内 | 约0.32 | 约0.3% |
ADMM迭代30次以内,最优解跟CVX直接求解的差值在0.5%以内,SOC轨迹几乎重合;而平均单步求解时间可以压缩到十分之一级别。这个差距决定了你后续能不能往快速控制原型或者嵌入式平台迁移。
看结果时,重点看SOC轨迹是否频繁触边。如果SOC经常触到上限或下限,多半是beta权重给低了,或者预测时域太短,让MPC为了短期油耗把电量用过头。这个现象跑出来一眼就能看到,不用等油耗结果出来再去判断。另外,如果只看累积油耗而不看SOC终值,对比是不公平的,要保证两个方案结束时的SOC尽量接近,才能说油耗差异是算法本身带来的。
5. 实际踩过的坑、常见问题与排查技巧
5.1 CVX报错:Disciplined convex programming error
这是最常遇到也最让新手头疼的错误。CVX建模时要做凸性检查,如果表达式不满足规范——比如变量乘以变量、变量取绝对值的符号方向不对、目标函数里用了非凸函数,它就会直接拒绝求解。解决办法是一行一行注释掉约束,找出哪个表达式不满足规则;如果模型本身就非凸,那就回到第1.3节去做凸化处理,而不是硬调代码。
我踩过最蠢的坑是目标函数里写了P_eng乘P_mot这种变量交叉项,CVX直接报错。节省时间的做法是从一开始把二次项写清楚:只写平方项,避免交叉项,或者提前做变量替换。这样就没必要在报错后一条条注释排查。
5.2 ADMM不收敛或收敛慢
迭代次数用完仍然没达到残差阈值,优先检查rho。rho太小,每次z更新步子迈得小,要磨很久才到最优;rho太大,又容易来回振荡,残差下不去。我一般做法是跑几组rho=0.1、1、10的对比实验,画出迭代次数和残差曲线,选在20次左右能收敛到1e-4以下的值。
还有一个容易被忽略的因素:两个子问题的目标函数量级差异太大。比如油耗项量级是10,SOC惩罚项量级是10000,ADMM很容易被大尺度的项主导而忽略另一项。给目标函数里的各项做归一化,或者提前把权重调平衡,收敛速度会有肉眼可见的进步。
5.3 控制量输出高频抖动
MPC滚动优化如果不加控制增量惩罚,在工况突变的地方,第一步控制量可能跳得很猛,仿真曲线像锯齿一样。这个问题在实车上就是平顺性灾难。解决办法是在目标函数里加入控制增量的二次惩罚,限制相邻两步控制量的变化幅度。加上之后,SOC曲线和功率分配曲线会平滑很多,代价是可能稍微牺牲一点最优性,这个取舍在工程上是合理的。
5.4 常见问题速查表
把一些高频问题整理成表,方便遇到问题时快速定位:
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| CVX报DCP error | 表达式不符合凸规则 | 逐条注释排查,对模型做凸化处理 |
| ADMM迭代上限用完 | rho不合适,或两项尺度不匹配 | 调rho,对目标函数各项做归一化 |
| SOC频繁触边 | beta过小,或Np过短 | 调大beta,适当加长预测时域 |
| 控制输出锯齿 | 缺少控制增量惩罚 | 目标函数加Δu二次项 |
| cvx_setup报错 | CVX版本与Matlab版本不兼容 | 去官网确认版本支持,重新运行setup |
| license提示错误 | 许可证文件与主机不匹配 | 检查hostid,重新申请匹配的许可证文件 |
排查这类环境问题有个基本思路:先确认CVX版本是否支持你的Matlab版本,再重新执行cvx_setup看详细日志;许可证问题就去找官网文档,检查hostid和许可证文件是否对应,别在网络环境上瞎猜。
最后再分享一个个人习惯:我在所有MPC求解函数里都会加一个可开关的调试模式,打印每个采样周期的迭代次数、残差和求解耗时。这个习惯在调参数时帮了大忙。如果正准备开始实现这套方法,我建议先从确定性工况跑通,不要上来就用真实采集数据或复杂整车模型,先把算法链路走通,再逐步加复杂度。这样不管代码出什么问题,你都能清楚问题出在哪一层。