混动系统的能量管理策略,论文里写得再天花乱坠,落到代码上最核心的也就是一句话:在每一个时刻,决定发动机和电池各自干多少活。而动态规划(Dynamic Programming, DP)算法,就是当前已知的、能把这个问题解到“全局最优”的少数方案之一。它的输出结果——理论最优油耗,既是整车控制策略的标尺,也是算法工程师对照验证其他实时策略(比如ECMS、MPC)的基准线。
这篇内容围绕我实际落地的一个程序项目展开:一个基于动态规划算法、针对混合动力汽车挡位与扭矩分配进行优化的油耗计算程序。这篇东西适合三类人来读:正在做混动能量管理策略仿真的工程师、需要为论文或项目生成“理论最优油耗”基线数据的研究生,以及想搞明白DP算法在实车上到底怎么算油耗的入门开发者。我会把程序的功能边界、底层原理、代码实现逻辑、坑和调优手段一次讲清楚。
1. 为什么混动油耗计算要上动态规划:全局最优的诱惑
1.1 从一场“策略调参”的困局说起
之前做某款P2架构混动车型的油耗仿真时,用基于规则(Rule-based)的策略控制发动机启停和扭矩分配。调参调到很晚,总算把WLTC工况下的仿真油耗从7.2L/100km压到了6.5L/100km。但心里一直不踏实:规则里的每个阈值都是“经验拍板”,从没有过“这已经接近物理极限”的实感。到底还有没有可能做到6.0L甚至5.8L?那个阶段最需要的,不是一个更好的规则,而是一把能测量“最优”的尺子。
这把尺子就是动态规划。它能在一个已知的完整驾驶循环(车速-时间序列)上,以最小总油耗为目标,反推出来每一个时刻发动机和电机应该分别输出多少扭矩、挡位挂几挡。它不追求在线实时计算,而是离线把“上帝视角”下的最优解算出来。有了这个结果,才知道规则策略离理论极限还差多远,能量管理算法的优化空间究竟在哪。
1.2 DP到底在混动上解了什么数学问题
混动能量管理问题本质上是一个带约束的最优控制问题。系统状态随驾驶工况一步步演进,控制输入决定每一刻的能量分配。把车速离散成N个时刻(比如WLTC循环按1秒采样,就有1800个时刻),DP做的事情就是:从最后一个时刻开始向前倒推每一步的最小代价,最终在第0时刻得到全局最优的代价和各时刻最优控制量。
这里涉及混动系统特有的几个约束条件,会直接影响油耗计算模型:
- 电池SOC(荷电状态)边界:通常限制在30%~70%之间。SOC太低损坏电池,太高没有回收空间。DP计算时会把这个边界作为状态空间的硬约束。
- 发动机工作范围:转速和扭矩必须落在该发动机的万有特性(BSFC Map)范围内,怠速以下的工况是不允许的。
- 电机功率限制:取决于电池可以提供的最大放电/充电功率。
- 挡位范围:比如6挡AMT,每个时刻挡位可选中1挡到6挡,挡位变化需要合理过渡限制。
DP算法最核心的优势在于:它不要求系统模型是线性的,发动机万有特性Map、电机效率Map、电池内阻模型等都可以是离散查表形式,这非常贴近工程实际——毕竟我们手里拿到的数据本来就是台架标定的离散Map表格。
1.3 一个油耗程序在设计前的自我审视
要做这个油耗计算程序,先想清楚它到底产出什么。我最终的需求是三个输出:
- 最优油耗值:整个循环总油耗(单位可以是mL或L),以及换算成L/100km的综合油耗。
- 最优控制序列:每个离散时刻对应的挡位、发动机扭矩、电机扭矩。
- SOC轨迹与发动机工作点分布:用于判断电池是否好好利用了、发动机工作点是否大多落在高效区。
这三个输出决定了代码的数据流设计。如果只想算一个油耗数值,那要简单得多,但要给仿真平台输出最优控制律,就必须把控制量保存下来,并以表的形式供前向仿真调用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 油耗计算程序的状态、决策与目标函数设计
2.1 状态量选几个最合适
DP的状态量选择直接决定了计算量的大小。状态越多,网格越密,维数灾难越明显。在这个项目中,我选了两个状态变量:
- SOC离散值:SOC范围0.3~0.7,按0.1%粒度离散,共401个点。这个值不是拍脑袋定的——原先用0.5%粒度(81点)跑出来的最优油耗有锯齿状抖动,主要原因是SOC网格太粗导致控制量取值不够平滑。用0.1%后,WLTC循环全程计算时间从2分钟涨到7分钟左右,但结果曲线平滑度明显改善。
- 挡位离散值:1~6挡,共6个点。
为什么不把车速和加速度也作为状态?因为对于给定工况序列(即已知完整车速轨迹)的离线优化问题,车速是随时刻固定的外部输入,不需要作为状态。这正是DP能高效求解的关键——工况是提前知道的。这一点很多刚接触的人容易搞混,总想用DP实时去算,但实际上DP天然适合离线计算,在线控制里用的通常是基于DP结果提取的规则或近似策略。
2.2 决策变量:发动机扭矩怎么离散
每个时步上,我的决策变量是发动机扭矩Te和挡位Gear。电机扭矩通过整车需求扭矩减去发动机扭矩求得,这样保证了车辆动力性——每个时刻都满足需求扭矩:
code复制T_req(t) = T_eng(t) + T_mot(t) * R_gear(t)
当然这里P2架构下需要按挡位传动比折算到轮端。简化公式方便写代码。电机扭矩用需求扭矩和发动机扭矩的差值反推,是一个代数关系,所以不单独作为决策变量网格,避免维度膨胀。
- 发动机扭矩离散:从0到该转速下最大扭矩,等间隔取若干点(比如50个点)。
- 挡位离散:1~6挡。
这样每一步的决策网格总数约为50 × 6 = 300个候选,对于DP的逆推循环来说还算能接受。
2.3 目标函数:除了油耗还要照顾什么
目标函数的设定直接决定DP的“偏好”。纯经济性目标是:
code复制J = Σ m_dot_fuel(t) * Δt
这里的m_dot_fuel是瞬时油耗率(g/s),根据发动机转速和扭矩查BSFC Map求得。但如果只优化油耗,可能出现一个现象:电池SOC在最后被压榨到边界最低点,因为电池放电不直接计价。虽然SOC边界约束保证了不会低于0.3,但末值SOC可能和初值SOC(比如0.5)不一致,这在对比策略时很不公平。
解决办法是引入SOC惩罚项,把“SOC偏差”折算成等效油耗加进目标函数,或者用罚函数把终值SOC偏差强制拉回初始值附近。我在程序里用的是二次罚函数:
code复制J = Σ m_dot_fuel(t) * Δt + α * (SOC_final - SOC_target)^2
α的选择会影响SOC终值与油耗的平衡。我之前用α=1000时,WLTC循环SOC终值还是漂到了0.44左右;后来调到α=10000,SOC终值才稳定到0.48以上。这个α本质上就是《混合动力汽车能量管理》文献里常说的“等效燃油因子”的离线版本。
3. 程序核心模块实现:从逆向递推到最优控制序列回代
3.1 程序整体架构
代码用Python写,核心引擎用NumPy矩阵运算加速,只有最内层的三层for循环用Numba JIT编译。整个程序分五个模块:
- 工况解析模块:读入WLTC/NEDC/CLTC等标准循环文件,插值平滑,计算出每个时刻需求扭矩和转速。
- 效率Model模块:读入发动机BSFC Map、电机效率Map、电池内阻+OCV表,构建插值函数。
- 逆向DP模块:从最后一个时刻向前递推,计算每个状态对应的最小累计代价和最优决策。
- 正向回代模块:从初始SOC和挡位出发,沿着最优决策正向推演,得到SOC轨迹、挡位序列、发动机/电机扭矩序列。
- 油耗统计模块:累加油耗,换算成L/100km,并绘制曲线图(用Matplotlib,主要用于人工检查)。
3.2 逆向递推的代码骨架
核心循环结构是这个样子的。为了讲解清楚,我贴的是简化版本,省略了插值和查表的边界处理细节:
python复制import numpy as np
from numba import njit
@njit(cache=True)
def dp_backward(soc_grid, gear_grid, tf_matrix):
"""
soc_grid: 离散SOC网格 (n_soc,)
gear_grid: 离散挡位网格 (n_gear,)
tf_matrix: 从时刻t到t+1的状态转移代价矩阵
shape: (n_soc, n_gear, n_soc, n_gear)
实际实现中无法预存这个四维矩阵,太耗内存,
通常是边算边填。这里仅展示逻辑结构。
"""
n_t = tf_matrix.shape[0] # 总时步数
n_soc = len(soc_grid)
n_gear = len(gear_grid)
# cost_to_go: 从时刻t到末时刻T的最小累计代价
cost_to_go = np.zeros((n_t + 1, n_soc, n_gear))
# opt_control: 记录最优决策(扭矩挡位编号等)
opt_control = np.zeros((n_t, n_soc, n_gear), dtype=np.int32)
for t in range(n_t - 1, -1, -1):
for i_soc in range(n_soc):
for i_gear in range(n_gear):
best_cost = 1e18
best_action = -1
# 遍历所有决策
for i_action in range(num_actions(t)):
# 根据当前状态和决策,计算下一时刻SOC和挡位
soc_next, gear_next = transition(t, i_soc, i_gear, i_action)
# 当前步瞬时油耗
fuel_now = fuel_rate(t, i_soc, i_gear, i_action) * dt
# 累计代价 = 当前步油耗 + 未来最优代价
total_cost = (fuel_now +
cost_to_go[t + 1, soc_next, gear_next])
if total_cost < best_cost:
best_cost = total_cost
best_action = i_action
cost_to_go[t, i_soc, i_gear] = best_cost
opt_control[t, i_soc, i_gear] = best_action
return cost_to_go, opt_control
3.3 状态转移里的SOC计算细节
SOC转移必须考虑电机的充放电效率和电池的充放电内阻差异。我用的公式是:
code复制P_batt = V_oc * I - I^2 * R_int
但实际计算时,我们通常反着算:给定电机功率P_mot,求SOC变化量。电池功率与电机功率的转换要考虑电机效率和逆变器效率,我们通常用一个综合效率:
code复制if P_mot >= 0: # 放电模式,电池输出功率大于电机输入功率
P_batt = P_mot / (eta_mot * eta_inv)
else: # 充电模式,制动回收或发动机带动电机发电
P_batt = P_mot * (eta_mot * eta_inv)
然后根据电池OCV和内阻R,解二次方程求电流I,进而计算SOC变化:
python复制def soc_delta(p_batt, soc, battery_cap_ah):
# 从OCV-SOC表插值得到开路电压
ocv = interp_ocv(soc)
# R_int随SOC变化,充电和放电内阻不一样,要分别查表
r_int = interp_r(soc, charging=(p_batt < 0))
# 二次方程: V*I - I^2*R = P_batt
# => I = (V - sqrt(V^2 - 4*R*P_batt)) / (2*R) (取合适根)
disc = ocv**2 - 4 * r_int * p_batt
if disc < 0:
return 0.0 # 超出电池能力,实际程序中要mark为无效状态
if p_batt >= 0:
i_current = (ocv - np.sqrt(disc)) / (2 * r_int)
else:
i_current = (ocv + np.sqrt(disc)) / (2 * r_int)
# dSOC = I * dt / (3600 * Cap)
return i_current * dt_sec / (3600.0 * battery_cap_ah)
这个计算里的坑在于内阻R和OCV都要区分充放电方向,否则SOC轨迹在回充段会明显偏乐观,最终算出来的油耗偏低,甚至比实测还要低好几个百分点。我第一次用单一内阻表时,回代出的电池SOC在高速回充段出现了不正常的“陡升”,一看就是内阻取值不合理。
3.4 正向回代怎么保证物理可行
DP逆向递推完成后,还需要正向回代得到实际可行轨迹。回代核心逻辑如下:
python复制@njit(cache=True)
def dp_forward(soc_0, gear_0, cost_to_go, opt_control):
n_t = opt_control.shape[0]
soc_traj = np.zeros(n_t + 1)
gear_traj = np.zeros(n_t, dtype=np.int32)
# ... 类似
soc = soc_0
gear = gear_0
soc_traj[0] = soc
for t in range(n_t):
# 找到当前状态在网格上的索引
i_soc = np.searchsorted(soc_grid, soc)
i_gear = np.searchsorted(gear_grid, gear)
# 取出最优决策
action = opt_control[t, i_soc, i_gear]
# 根据决策计算当前时刻的Te, Tm, 挡位
te, tm, gear_new = decode_action(t, action)
# 根据决策和当前状态计算下一时刻SOC
soc = soc + soc_delta(...)
soc = clip(soc, 0.3, 0.7)
gear = gear_new
soc_traj[t + 1] = soc
gear_traj[t] = gear
return soc_traj, gear_traj
这里有个需要重点注意的工程问题:正向回代时,当前SOC往往不在网格点上,需要通过插值取“近似最优决策”。用最近邻还是线性插值,会影响SOC轨迹的光滑度。我在代码里用的是“线性插值成本对比”,即比较两侧网格点的cost_to_go值,选择期望代价更小的一侧对应的决策。实测比单纯最近邻更平滑。
4. 油耗计算精度与速度的博弈
4.1 离散网格密度对结果的影响
DP的油耗结果高度依赖于SOC网格密度和发动机扭矩网格密度。下面是我在WLTC循环上做的灵敏度测试(同一台P2混动参数):
| SOC网格粒度 | 发动机扭矩网格点数 | 计算耗时 | 最优油耗 (L/100km) |
|---|---|---|---|
| 1.0% | 20 | ~2分钟 | 4.71 |
| 0.5% | 30 | ~4分钟 | 4.69 |
| 0.2% | 40 | ~6分钟 | 4.67 |
| 0.1% | 50 | ~7分钟 | 4.67 |
| 0.05% | 50 | ~12分钟 | 4.67 |
从数据可以明显看到:SOC网格从0.5%加密到0.1%时,油耗差值不到0.02L/100km,加速从0.5%到0.2%计算时间近1.5倍。所以实际工程选型时,不需要盲目追细网格,0.2%~0.5%的SOC粒度加上30~40个发动机扭矩点已经能提供足够的工程精度。
4.2 Python能跑,但需要给性能留后路
这个程序最初用纯Python写,外层两个循环加内层300个决策,WLTC跑一次要30分钟以上,调试过程几乎每改一次参数都要等半小时。后来用Numba做了三处优化:
- 所有二维/三维数组预先分配,避免在循环里动态append列表。
- 插值函数用Numba的interp替代SciPy的interp1d——后者在JIT环境下不兼容,就直接改写为lookup + linear interpolate,这个函数反而更快。
- 最内层循环里的物理公式全部标量化,不要在循环里调自定义Python函数。
优化后WLTC单次计算压缩到7分钟左右。如果还嫌慢,可以调整NumPy并行,或者用Cython重写核心循环。不过对常规策略验证来说,7分钟完全够用。
4.3 边界条件的隐性地雷
边界条件这块,我踩过一个非常隐蔽的坑。发动机扭矩网格上限要取“该转速下的最大扭矩”,但这个扭矩会随转速变化。ECU中有外特性曲线,如果直接把最大扭矩设成固定值(比如200Nm),发动机在某些低转速点会请求超过外特性的扭矩,导致计算出的油耗无意义。后来修正为:在DP循环的每个时步,根据当前转速查外特性Map,将发动机扭矩网格上限动态设为该转速的最大扭矩。同理,电机驱动扭矩上限也要随转速动态变化。
5. 一个油耗程序的验证与复盘
5.1 最优油耗结果的物理合理性
程序跑通后,第一件事不是看油耗数字,而是看曲线上有没有异常。验证有四个维度:
- SOC轨迹:应该在0.3~0.7范围内波动,终点接近初始值(在惩罚项作用下)。
- 发动机工作点散布:绝大多数应该落在BSFC图的高效区(油耗率较低的区域)。如果很多点落在低效区,说明目标函数约束设置有问题。
- 挡位变化:挡位序列应当平滑,不应该出现频繁反复换挡。
- 总油耗曲线:累计油耗应该是单调上升的曲线,不该有负增长。
以WLTC低速段为例,DP计算出的挡位序列一般为2挡为主,部分工况下会挂1挡。而发动机扭矩输出一般较为平缓,相较规则策略更少出现猛踩油门或急加速。这是因为DP“看到”了整个工况的未来,会提前在低速时用电机驱动,让发动机工作在更平稳、更高效的区间。
5.2 与规则策略的对比
之前基于规则的策略在同一个WLTC循环上(初始SOC 0.5,终点SOC 0.48),仿真油耗是6.5L/100km。DP的全局最优结果是5.1L/100km。两者差距超过1.4L/100km——这个差距在整车油耗目标中是决定性的。当然要指出:DP的最优不能直接实现在线控制,因为实际车不知道未来工况;但规则策略的1.4L/100km提升空间,给了后续开发明确的缺口方向。
后续我们还从DP结果中提取了挡位选择规律和发动机启停规则,用来优化规则策略,最终规则策略的仿真油耗降到了5.8L/100km左右。这就是用DP做离线最优标定的价值——它不直接上车,但能告诉你规则该怎么定。
5.3 油耗计算程序的可移植性改进
这个程序还可以做两个方向的扩展:
- 支持自定义工况输入:目前代码读入标准格式的工况文件(时间列、车速列),可以方便地换成CLTC、NEDC或者实际路采的数据。对于实际路采数据,需要先做加速度平滑处理,否则DP会在剧烈波动处产生异常控制。
- 支持多目标扩展:把NOx排放、发动机启停次数等内容加入目标函数,加上权重系数即可得到权衡解。需要注意的是,多目标加入后,中层的权重调节需要重新做灵敏度测试,不能沿用单一油耗目标的权重。
6. 这个程序留给后续迭代的三个注意点
最后分享几个这次开发中比较深的体会。写DP油耗程序,最容易犯的错误是“以为跑出个数字就完了”。实际要让结果可信,还得盯住下面三个点:
第一,电池SOC初值和末值不一致时,油耗对比必须做等效折算。 如果DP算完的SOC终值比初值低2%,意味着这2%的电量被“白嫖”当成了油耗减免。程序里SOC惩罚项系数α需要大到让SOC终值与初值的差小于0.5%,这样对比才是公平的。
第二,BSFC Map的插值方法对油耗结果影响很大。 不同Map的等高线疏密程度差异很大,用线性插值可能在高转速低负荷区出现过冲。我在代码里采用了双线性插值,同时在Map边界做了外插保护。注意:有些Map在低转速区域没有数据点,一外插就容易得到离谱的负油耗率,所以边界裁剪必须做。
第三,把DP结果做成表格输出给前向仿真器时,注意插值平滑。 由于DP离散网格导致控制量有台阶现象,直接用于前向仿真可能会引起转速波动。我的做法是先对最优控制序列做中值滤波(窗口长度3),再在时序上做线性平滑。这样虽然牺牲了极小量的“数学最优性”,但换来了物理可实现性和仿真稳定性。这一步在实车标定中尤其重要。
回到开头那个问题——混动车油耗还能降多少?如果你手里的策略算出来是6.5L/100km,而这个程序算出来是5.1L/100km,那说明至少还有百分之二十的瓶颈没打通。动态规划油耗计算程序就是那把替你把“潜力空间”量出来的尺子。下一轮迭代,方向自然就清楚了。
