ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

混动油耗优化中的动态规划:从建模到代码实现

混动油耗优化中的动态规划:从建模到代码实现 1. 混动油耗优化为什么绕不开动态规划做混合动力汽车能量管理的人对动态规划算法这几个字应该都不陌生。只要聊到油耗最优问题DP几乎是所有文献里第一个出现的基准方法。我做整车控制策略这些年见过不少应届生或者刚转行做混动标定的朋友一上来就啃论文里的公式推导结果卡在为什么这么算程序从哪下手上。这篇文章想换个角度从一套实际跑通的油耗计算程序出发把动态规划在HEV上怎么建模、怎么离散、怎么写代码、怎么排查问题完整讲一遍。先说说这套程序到底解决什么问题。混合动力汽车有两个动力源——发动机和驱动电机同一个车轮需求功率可以由发动机单独出也可以电机单独出还可以两者一起出甚至发动机一边驱动一边给电池充电。同一个驾驶工况功率分配方案千千万万对应的油耗也天差地别。动态规划算法的作用就是在一个给定的行驶工况比如WLTC、NEDC或CLTC下离线扫描所有可能的功率分配路径找出一条让整段路累计油耗最低的最优控制序列。这个结果通常被当作理论最优油耗用来评价其他实时策略比如规则策略、ECMS等效油耗最小策略到底离最优有多远。一句话概括动态规划算法算的不是瞬时油耗而是整段工况的全局最优油耗。这就是它和PID、查表逻辑这类实时策略最本质的区别——前者把整条路看成一盘棋来下后者只管眼前这一步。这套程序适合谁来参考如果你是做混动控制策略的工程师想搭一个离线油耗评估工具或者你在读研需要复现论文里的DP基准结果又或者你只是好奇混动车的最优油耗是怎么算出来的——这篇文章里从建模思路到Python代码再到参数标定都会给到可以直接用的内容。1.1 从矩阵连乘说起最优子结构是DP的地基动态规划不是混动领域的专有名词。最近计算矩阵连乘问题最优解的动态规划算法这个经典例题又被翻出来讨论它其实是理解DP本质的绝佳入口。矩阵连乘问题是这样的有一串矩阵 A1, A2, ..., An相邻矩阵的维度分别为 p0×p1, p1×p2, ..., pn-1×pn。矩阵乘法满足结合律所以括号加在哪里不影响最终结果但严重影响标量乘法的总次数。比如三个矩阵维度分别是 10×100、100×5、5×50先算前两个需要 10×100×55000 次乘再乘第三个加 10×5×502500 次总共7500次如果先算后两个只需要 100×5×5025000 次再加上 10×100×5050000 次总计75000次——差了整整十倍。目标就是找到最优加括号方式。动态规划的经典写法是定义 m[i][j] 表示从第 i 个到第 j 个矩阵连乘的最小乘法次数然后有递推关系m[i][j] min { m[i][k] m[k1][j] p[i-1] * p[k] * p[j] }, i ≤ k j这个式子看着简单背后藏着DP的两个核心前提一是最优子结构即整体最优解里任何一段子问题从 i 到 k、从 k1 到 j也必须是最优的否则把子问题替换成更优方案就能让整体更优二是重叠子问题即不同区间的计算会反复用到同样的 m[i][j]所以用表格存下来避免重复递归。把这两个前提映射到混动油耗问题上你会发现完全对应。整段工况的最优功率分配序列其任意时间片段上的子序列也必须是该片段的最优——假如前半段还有更省的跑法直接把后半段最优策略拼上去就能让全段更省。同时不同路径可能收敛到同一个SOC状态点上后续的代价计算被反复复用。这就是为什么动态规划算法能精确求解混动油耗问题而穷举法在时间维度上根本撑不住。1.2 整车能量管理如何套进DP框架把混动油耗问题翻译成动态规划的标准语言需要定义五样东西阶段、状态、控制、状态转移、代价函数。阶段就是时间步。把整个工况按秒离散化WLTC有1800秒CLTC有1800秒NEDC有1180秒每秒算一个阶段一共有N个阶段。状态选的是电池SOC荷电状态。为什么是SOC而不是车速或者挡位因为车速和需求功率在工况里是预先给定的外部输入不是系统的内部状态而SOC是唯一在能量管理决策下持续演变、且直接制约未来可用电量的变量。对单状态DP来说SOC一个变量就能撑起整个问题。控制选的是发动机和电机之间的功率分配。具体形式可以写成发动机输出扭矩 T_eng或者写成电机功率 P_mot。知道了SOC和需求功率再给定 T_eng电机的功率就被功率平衡关系唯一确定了。状态转移是电池模型。第k步的SOC经过一个控制步长之后变成新值核心关系式是SOC(k1) SOC(k) - (Q_batt / Q_max) × Δt其中Q_batt是当前步电池净充放电电量。如果走等效电路模型更常用的是基于开路电压和内阻的公式P_batt U_oc × I_batt - I_batt² × R解出电流I_batt再积分得到SOC变化量。注意我习惯把放电定为正值这样SOC表达式里就是减号充电时P_batt为负SOC自然回升。代价函数定义为该时间步消耗的燃油质量一般从发动机万有特性BSFC油耗MAP插值得到。终点还有一个终止罚项用来约束终态SOC不要偏离目标值——这一项非常关键后面专门展开讲。把这五样东西写清楚之后动态规划的逆向递推就有了数学基础程序也可以开始搭了。2. 油耗计算程序的功能模块与整体设计我最初写这个程序的时候犯过一个典型的错误——想一上来就写最核心的递推循环结果代码揉成一团改一个参数要翻半天。后来重构成了四个模块输入解析、逆向递推、正向回放、后处理。每个模块各干各的事参数配置集中在文件头部换工况、换车型只动配置文件。这套结构建议直接用。2.1 四个模块划分输入解析、逆向递推、正向回放、后处理输入解析模块负责三件事读取行驶工况文件车速vs时间典型格式是CSV两列、读取整车参数质量、风阻系数、滚阻系数、传动效率、电池容量等、读取部件效率MAP发动机BSFC图、电机效率图。工况数据我会先做一次清洗把车速序列里的跳变点平滑掉因为DP对剧烈瞬态很敏感采样毛刺会直接污染最优决策。逆向递推模块是这个程序的心脏。它的任务是从工况终点往前推对每一个时间步、每一个可能的SOC网格点、每一个可能的发动机扭矩计算出从该状态到终点的最小累计油耗并记录当前最优决策。这一步不生成实际控制轨迹只生成一张巨大的代价表和一张最优决策表。正向回放模块负责从初始SOC出发沿着逆向递推得到的最优决策表正向推一遍工况得到实际的SOC轨迹、发动机/电机扭矩序列、逐秒油耗序列。这一步相当于把最优策略真正执行了一遍。后处理模块输出三个东西总油耗折算成L/100km、SOC随时间变化曲线、发动机工作点在BSFC图上的分布散点。第三项特别有用可以直观看到DP把发动机点都压到了低油耗区这也是判断程序跑得对不对的快捷方式。2.2 状态、控制与代价建模前必须先想清楚的三个问题很多初学者栽在同一个坑里上来就写代码没想清楚SOC范围开多大、扭矩离散步长取多少。建模阶段这三个问题必须提前定。第一SOC状态范围。HEV一般限定在0.3~0.8之间PHEV可以放宽到0.1~0.9。范围太宽会导致状态网格点过多、计算量暴增太窄又可能把最优路径的可行域截断出现无解。我的经验是先按电池手册的允许工作区间定范围再根据试跑结果微调——如果发现SOC频繁顶到上边界说明范围给定不对或者罚函数权重太小。第二控制量的离散粒度。发动机扭矩从最小值到最大值步长取多少直接决定计算量。步长1Nm太密一个100kW的发动机要扫100多个点乘上SOC的50个点、乘上1800秒即便逆向递推也要几百万次状态转移步长10Nm又太粗最优工作点可能落在两个网格之间油耗结果不够准。我常用的折中是5Nm配合发动机扭矩上限在200Nm左右的范围控制网格40个点左右计算速度和精度比较平衡。第三代价函数要不要把电耗折算成油耗。这个问题最容易被忽略。严格来说DP在逆向递推时不需要像ECMS那样把电池电量折算成等效油耗因为终点SOC罚函数已经隐式地给电能定价了如果终点SOC比目标值低罚函数会给出一个很大的代价优化器自然不敢肆无忌惮地用电。但如果你的工况特别短电池电量还没被惩罚到就会出现油耗极低但SOC掉了一大截的虚假最优。解决方法是加一个随工况长度自适应调整的罚函数权重或者直接用油耗 λ×(SOC_N - SOC_target)²的形式把SOC回归目标值做成硬约束的一部分。2.3 逆向递推与正向回放的数据流逆向递推的数据流可以用一张二维表理解行是时间步k列是SOC离散点表里存的是从第k步、该SOC出发到工况终点所能达到的最小累计油耗记作J(k, SOC_i)。这个表从最后一步往前逐列填充每一步都用贝尔曼方程J(k, SOC_i) min { fuel(k, T_eng) J(k1, SOC_next) }对每个T_eng候选值先算SOC_next然后在J(k1, ·)那一行上做线性插值取最小值。正向回放则是另一个方向给定SOC(0)在决策表里查每一步应该用哪个T_eng然后把SOC推进到下一步。它本质上是在走一条最优路径。数据流上两者完全对称逆向填表、正向走路。这里有个工程细节必须提醒逆向递推算出来的最优决策是定义在SOC网格点上的但正向回放时SOC实际值大概率落不到网格点上所以决策表也要做线性插值。我在程序里用numpy的interp函数处理一步到位。别忘了对SOC边界做clamp——如果插值点落在SOC范围之外要直接给一个巨大代价防止程序静默地允许非法SOC值。3. 关键参数的工程选择与实现细节建模想清楚之后程序的精度和效率就取决于一堆参数细节了。这一节讲的都是我实际调试时踩过的点每一条都对应过真实翻车现场。3.1 SOC网格密度精度和算力之间的拔河SOC离散间隔是DP程序里第一个要调的参数。取0.001太密网格上百个点状态转移矩阵的内存占用和计算次数都会失控取0.05太稀SOC轨迹上会出现明显的阶梯效应而且插值误差会累积到油耗上。我的经验值SOC范围0.3~0.8、间隔0.01网格51个点这是性能和精度的甜点区。对于标准工况1800秒、控制30~50个点单次完整DP计算在普通笔记本上大概跑1~3分钟完全可接受。如果你只是要快速迭代标定可以先用间隔0.02粗跑确定策略趋势后再用0.005精跑一次把最优油耗数值敲定。注意粗筛和精跑之间不要只改间隔罚函数权重也要重新调因为网格密度变化会影响插值误差对代价的干扰程度。3.2 发动机油耗MAP与功率平衡约束的处理发动机油耗MAPBSFC万有特性是代价函数的唯一数据源。MAP的输入通常是发动机转速和扭矩输出是燃油消耗率g/h。这个MAP一般来自台架试验实际程序里存成二维查找表。转速范围从怠速800rpm到最高6000rpm扭矩从0到外特性上限。功率平衡约束是整个DP程序里最容易出bug的环节。在P2构型单轴并联混动里某一时刻车轮需求功率P_req传动路径上的平衡关系是P_req (P_eng P_mot) × η_trans其中P_eng是发动机输出到曲轴端的机械功率P_mot是电机功率正值驱动、负值发电η_trans是变速箱传动效率。已知P_req和T_eng时电机功率就被这一条式子唯一确定了。但还要检查电机功率是否落在电机效率MAP的可行范围内、电池功率是否在充放电峰值内。这两条约束不满足的控制量必须直接剔除而不是靠罚函数软约束——否则程序会把一个物理上根本做不到的最优解算出来正向回放时才发现执行不了。处理顺序建议是先算发动机可行扭矩集合再遍历SOC点算电池可行性最后才进入DP的min运算。把不可行分支提前滤掉能省下大量无效计算这个顺序优化对总耗时的改善经常在30%以上。3.3 终端SOC罚函数怎么把偷电行为堵死动态规划有个天然缺陷如果只目标函数里只写油耗优化器会想办法把所有电池电量耗干因为电在目标函数里是免费的。终端SOC罚函数就是专门堵这个漏洞的。我习惯用平方罚加线性罚的混合形式g_N(SOC_N) α × (SOC_N - SOC_target)² β × max(0, SOC_min - SOC_N) γ × max(0, SOC_N - SOC_max)平方项让终点SOC温和回归目标值两组线性项分别惩罚越界。α是核心调节对象太小终点SOC会飘太大优化器会为了保SOC而牺牲油耗DP退化成保电策略。α的标定经验值是让终点SOC在正反跑几次之后稳定在目标值±0.5%以内。怎么判断正向回放结束看一眼SOC_N如果偏大就调大α偏小就调小一般试两三轮就能锁定。更重要的一点比较油耗时SOC终点要对齐。不同策略如果SOC_N不一样油耗数字没有可比性。标准做法是把DP结果、规则策略结果都跑完后用统一的等效油耗公式把电量差折算成油耗修正量fuel_eq fuel_total (SOC_0 - SOC_N) × Q_batt × U_oc_mean / (LHV_fuel × η_avg)其中η_avg是发动机平均工作效率。这一步做完对比才有意义。4. 实操案例WLTC工况下完整跑一遍DP油耗计算光讲参数太干下面用一个实际算例把流程串一遍。车型假设是一台典型的P2构型混动车整备质量1650kg发动机最大功率90kW驱动电机峰值功率60kW电池可用能量1.3kWh折合约4.5Ah、平台电压320VSOC工作区间0.3~0.8目标值0.55。工况用WLTC1800秒、里程23.27km。4.1 整车与工况参数准备底盘参数和阻力系数按常用经验值填写滚阻系数0.012风阻系数0.32迎风面积2.2m²传动效率0.92。需求功率的计算公式是P_req v × (m × g × f 0.5 × ρ × Cd × A × v² m × a) / η_transρ空气密度取1.2kg/m³。我算两个点给你找找感觉WLTC里有一段120km/h等速车速33.3m/s此时滚动阻力约194N空气阻力约469N功率需求大约24kW低速市区段50km/h等速空气阻力只有81N总需求功率4kW出头。这个差距决定了混动车低速基本可以纯电滑行高速必须发动机介入DP的决策也会自然反映这一点。工况数据我在程序里做的是每秒一个点WLTC就是1800个点转速和扭矩的初始化不需要额外处理直接读入。4.2 逆向递推核心代码与关键变量说明核心递推代码其实不长我贴一个经过实际验证的简化版结构import numpy as np # 参数配置 N len(v_spd) # 时间步数WLTC1800 SOC_grid np.arange(0.30, 0.81, 0.01) # SOC离散网格 T_eng_opts np.arange(T_min, T_max, 5) # 发动机扭矩候选集 # 代价表与决策表 J_cost np.full((N 1, len(SOC_grid)), np.inf) opt_u np.zeros((N, len(SOC_grid))) # 终点罚函数 for i, soc in enumerate(SOC_grid): J_cost[N, i] alpha * (soc - SOC_target) ** 2 \ beta * max(0, SOC_min - soc) \ gamma * max(0, soc - SOC_max) # 逆向递推从最后一步往前 for k in range(N - 1, -1, -1): P_req road_load(v_spd[k], a_spd[k]) # 当前时刻需求功率 for i, soc in enumerate(SOC_grid): best_cost np.inf for te in T_eng_opts: # 功率平衡求电机功率 P_mot (P_req / eta_trans) - te_to_power(te, rpm[k]) if not motor_feasible(P_mot): continue # 电池电流与SOC转移 I_batt (U_oc - np.sqrt(U_oc**2 - 4 * R_batt * P_batt)) / (2 * R_batt) soc_next soc - I_batt / (Q_batt * 3600) * dt if soc_next SOC_min or soc_next SOC_max: continue # 线性插值取下一步代价 j_next np.interp(soc_next, SOC_grid, J_cost[k 1]) cost te_to_fuel(te, rpm[k]) * dt j_next if cost best_cost: best_cost cost opt_u[k, i] te J_cost[k, i] best_cost注意几个关键变量te_to_power和te_to_fuel都是从BSFC MAP插值的函数motor_feasible检查电机功率是否在峰值和持续功率范围内电池电流公式里的U_oc和R_batt随SOC小幅变化我这里做了简化处理精度要求高时可以改成查表。J_cost第一行k0的值就是从初始SOC出发的最优累计油耗正向回放时要用它来反查最优决策。顺带提一句如果你在学动态规划时看到过矩阵连乘的递推写法会发现这里的思想完全一致矩阵连乘的m[i][j]表是按区间长度递推的这里按时间步递推区别只是这里的状态转移多了一步插值代价多了一堆查表操作。4.3 结果分析油耗数据与SOC轨迹怎么看正向回放跑完典型结果长这样WLTC全段23.27kmDP最优油耗在4.1~4.3L/100km左右SOC终点落在0.545~0.555附近说明罚函数权重调得还行。作为对比一套常规的规则策略低速纯电、发动机只在扭矩需求超过阈值时启动充电功率按固定值在这个车型上一般跑出5.0~5.4L/100km。也就是说DP比规则策略低15%~20%这个数字在工程上是合理的——DP把发动机工作点几乎全部压进了BSFC的低油耗区同时把制动能量回收的效率充分利用起来。看结果时有几件事要重点检查。第一看SOC轨迹是否出现了不合理的抖动。DP算出的SOC应该是平滑缓变的如果轨迹像锯齿一样高频震荡多半是控制网格太粗或者罚函数权重不匹配需要回头调参。第二看发动机工作点分布散点图如果大量点集中在BSFC MAP的右下角高转速大扭矩区域说明策略倾向于用发动机高效区如果散点散得到处都是程序可能还有bug。第三把逐秒油耗序列画出来与工况加速度曲线对齐看——急加速阶段油耗尖峰出现的位置应该与工况峰值加速度基本对应如果错位时间对齐可能出了问题。5. 常见问题与排查技巧实录这部分内容提纲里原本只列了三点但实际调试中遇到的问题远不止这些。我挑出现频率最高的四条给你一份可以直接对着查的排错清单。5.1 终端SOC不回归目标值这是我最常被问到的问题。我罚函数加了alpha也调了为什么跑完SOC还是只有0.48先别急着调alpha。第一步检查是不是SOC可行域把最优路径切断了——比如工况里有一段特别长的大功率爬坡电池功率上限撑不起电机单独出力发动机又已经工作在最大点SOC只能一路往下掉这时候你加多大罚函数都没用这是物理极限不是算法问题。判断方法很简单把SOC下边界放宽到0.2再跑一次如果终点SOC能回到0.5附近说明是可行域太紧如果还在0.48再考虑调罚函数。第二步检查罚函数里有没有把线性越界项写对。常见乌龙是max(0, SOC_min - soc)写成了max(0, soc - SOC_min)结果越界罚变成了奖励程序拼了命往边界外面跑油耗还特别低。这种bug几乎看不出来建议对罚函数做单步单元测试手动构造一个SOC偏离目标的状态直接调g_N函数确认数值方向是否正确。5.2 计算耗时爆炸与内存溢出标准单状态DP很少真的内存溢出但如果你扩展成了双状态比如SOC加发动机开关状态或者SOC加电池温度状态网格从50个点变成50×10500个点控制变量再一多计算量就上来了。此时三个优化手段按优先级排列第一在控制遍历前提前剔除物理不可行的扭矩点这一步通常能砍掉30%~50%计算量第二把MAP插值提前做成查找表不要在循环里反复调用插值函数——我见过有人用scipy.interpolate在万级循环里逐点查慢得让人崩溃换成一维查表后速度快了二十倍第三对J_cost表用float32存储而不是float64内存直接减半精度损失在油耗结果上的影响可以忽略。如果单次DP想压进10秒以内还有一个思路把工况时间步从1秒合并成2秒。代价是瞬态工况下的最优性打了折扣但作为趋势评估完全够用。5.3 DP油耗与实车差异大的原因DP算出4.2L/100km上了台架或者实车一跑变成5.6L/100km这个差距需要冷静分析。第一DP是离线全局最优它假设未来整个工况已知实车策略只能看当前和有限预测信息这个信息差本身就值5%~8%的油耗差第二DP模型里没有温度效应——发动机冷启动油耗、电池内阻随温度变化、润滑摩擦瞬态这些在简化DP模型里统统没有体现冷机工况下这部分差异会更大第三DP的制动能量回收默认效率恒定但实车再生制动受ABS介入、踏板感觉标定影响实际回收能量要打折。所以DP结果的正确定位是理论天花板和策略对比基准而不是实车目标值。工程上更合适的做法是用DP的功率分配趋势去指导规则策略的阈值标定比如看DP在什么车速、什么加速度下启动发动机把规则策略的介入门限往那个方向调再上仿真验证。这条路我实测走下来很顺。5.4 一个隐藏很深的坑工况时间对齐偏移最后分享一个真事。有一次我换了新版本的工况文件跑出来的DP油耗比之前低了8%一开始以为是优化效果后来对逐秒数据才发现新工况文件整体比旧文件提前了一秒——时间对齐偏移导致DP预知了一秒的未来信息油耗自然虚低。从那以后我每换一次工况数据都会先画一张车速对比图或者做一次互相关校验确保时间轴严格对齐。这个检查虽然简单但在DP程序的长期维护里价值极高因为工况文件版本更新是常态。6. 一点个人体会动态规划算法在混动油耗计算上的应用说难没有多难说简单也绝对不简单。核心模型就那几行递推公式但真正决定程序好不好用的全是网格密度、罚函数、MAP查表、可行性预判这些细节工程。我个人的体会是想把这套程序做成一个可信的工具一半精力花在建模另一半精力必须花在验证和排错上——尤其是终端SOC对齐和工况时间对齐这两个环节它们直接决定了油耗结果的可靠性。如果你只是临时需要一个油耗基准值照着第4节的代码骨架改改参数就能跑。但如果你打算把它当成长期可复用的策略评估工具建议在架构上就把参数配置、模块解耦、结果可视化一次做到位后续迭代省下的时间远超最初多投入的那几天。后面有机会我再写一期聊聊怎么把DP离线最优结果转成在线可用的规则策略以及DP和ECMS结果之间怎么互相换算。那部分内容踩过的坑比这期还要多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进