一次长时间的 PyBaMM 退化模拟在第 300 圈崩掉,SUNDIALS 吐出一大段输出,末尾是 Corrector convergence failed repeatedly or with |h| = hmin 之类的字样。第一反应通常是去调容差或者把步长改小。这个反应基本上是错的,而且顺着它走会浪费掉几个小时。
这篇讲的是怎么区分两种情况。收敛失败要么是数值问题,要么是物理问题,而两者的修法方向相反。在一个物理上不可能的状态上放宽容差,救不回这次运行——它只是把”模型从哪里开始失去意义”这件事藏起来,然后让求解器吐出一批你之后会当真的数字。
一、这条报错到底在说什么
IDAKLU 是 BDF 求解器:每一步构造一个隐式代数方程组,用牛顿迭代去解。所谓 “corrector” 就是那个牛顿循环。有两件事会终结一步:
- corrector 不收敛。牛顿迭代跑到上限,残差仍然没降下去。求解器的反应是缩小步长重试。
- 步长撞到
hmin。反复缩小之后已经无可再缩。这就是|h| = hmin的含义。
关键推论就藏在这个顺序里:当你看到这条报错时,求解器已经把步长缩小过很多次了。手动调小步长或输出间隔,是在重复自适应控制器已经做过且失败了的事。如果缩步长有用,它早就有用了。
所以这条消息不是”步长太大”,它更接近于”在这个状态下,我愿意采用的任何步长都无法让牛顿迭代找到解”。
二、真正要先做的判断:数值还是物理
在动任何一个求解器选项之前,先确定你面对的是哪一种。这个测试很便宜。
import pybamm
# 用更简单的模型跑同一个实验。SPMe 放弃了 DFN 的空间分辨率但保留电解液梯度,
# 所以它能挺过一些 DFN 表达不了的状态。
for cls in (pybamm.lithium_ion.SPMe, pybamm.lithium_ion.DFN):
sim = pybamm.Simulation(cls(), parameter_values=params, experiment=experiment)
try:
sol = sim.solve()
print(f"{cls.__name__}: 完成 {len(sol.cycles)} 圈")
except Exception as exc:
print(f"{cls.__name__}: 失败 - {type(exc).__name__}: {exc}")
结果这样读:
| SPMe | DFN | 说明 |
|---|---|---|
| 同样失败 | 失败 | 物理问题。参数把电芯推进了两个模型都无法表示的状态,调求解器选项不会有任何帮助。 |
| 能跑完 | 失败 | 大概率仍是物理问题,只是局限在 DFN 会解析而 SPMe 平均掉的那部分——最常见的是某处浓度越界。 |
| 能跑完 | 换选项后能跑完 | 数值问题。只有这一种情况下,调求解器才是对的动作。 |
实践中长时间退化模拟里第一行最常见,而它恰恰是人们用错工具、耗时最久的那一种。
三、找出那个物理上不可能的状态
当答案是物理问题,问题就变成了哪个变量越了界。嫌疑对象是那些方程默认会待在范围内的有界量:
- 颗粒表面浓度触到 0 或最大值。Butler-Volmer 动力学里含有
c_s_surf和(c_s_max - c_s_surf)项,任一趋近于零,交换电流密度就塌掉、过电势就发散。 - 电解液浓度在隔膜或某个孔隙里降到零,电导率和扩散系数随之无定义。
- 孔隙率被 SEI 生长耗到零。这一条是长时间退化模拟特有的,也正是”模拟能跑完 250 圈却在第 251 圈失败”的原因。
看清楚它的方法是解到失败前再检查,而不是盯着求解器日志:
import numpy as np
# 先跑一段能成功的较短运行,再看有界变量离各自的极限有多近。
# 一次"跑通了但已经贴边"的运行,和几十圈之后的那次失败是同一回事。
sol = sim.solve()
for name in ("Negative particle surface concentration",
"Positive particle surface concentration",
"Electrolyte concentration [mol.m-3]",
"Negative electrode porosity"):
try:
v = sol[name].entries
except KeyError:
continue # 变量名随模型选项而变,缺了就跳过,别让整个诊断崩掉
print(f"{name:<44} min={np.min(v):.4g} max={np.max(v):.4g}")
化学计量数达到 0.999 或 0.001、电解液浓度比初值低一个数量级、孔隙率朝零走——这些都指向同一个结论:实验在要求电芯做它做不到的事。修法在协议或参数里,不在求解器里。
四、确实是数值问题时,有哪些选项
下面是 IDAKLU 文档记载的默认值。知道这些数字很重要,因为论坛上多数建议在调它们时并不说明原值是多少。
| 选项 | 默认值 | 什么时候该动 |
|---|---|---|
rtol |
1e-4 |
构造函数参数,不在 options 里。收紧费时间,放宽会掩盖误差。 |
atol |
1e-6 |
当某个状态变量的自然量级远离 1 时值得重新考虑。 |
max_num_steps |
100000 |
超长实验没报错却没跑够请求圈数时调高。 |
max_nonlinear_iterations |
40 |
调高让牛顿每步多干活;对"刚性但可解"有效,对奇异状态无效。 |
max_convergence_failures |
100 |
调高只是推迟同一个失败。主要用来确认它不是偶发。 |
nonlinear_convergence_coefficient |
0.33 |
放宽牛顿的接受阈值。最后再试,且事后必须重新验证结果。 |
max_order_bdf |
5 |
降到 2–3 能稳住带剧烈跃变的运行,代价是变慢。 |
jacobian |
"sparse" |
不要动。对离散化的电池模型,稀疏是对的。 |
linear_solver |
"SUNLinSol_KLU" |
除非性能分析另有结论,否则不要动。 |
num_threads |
1 |
只在批量求解多组参数时调高;它不会让单次求解变快。 |
陷阱:设了却静默不生效的选项
IDAKLU 的 options 字典只在 model.convert_to_format == "casadi" 时生效。在其他格式的模型上设置它们,会被照单全收然后悄悄忽略。你会得出"这个选项没用"的结论,而它根本没被应用过。
solver = pybamm.IDAKLUSolver(
rtol=1e-6,
atol=1e-8,
options={"max_nonlinear_iterations": 100, "max_order_bdf": 3},
)
model = pybamm.lithium_ion.DFN()
# 用断言而不是相信默认:这一行如果不是 "casadi",上面那组 options 全是摆设
assert model.convert_to_format == "casadi", (
f"convert_to_format 不是 'casadi' 时 IDAKLU 的 options 会被忽略;"
f"当前是 {model.convert_to_format!r}"
)
sim = pybamm.Simulation(model, parameter_values=params,
experiment=experiment, solver=solver)
五、为什么长跑会失败而短跑不会
只在几百圈之后才出现的失败,几乎从来不是"第 3 圈还合适、第 300 圈就不合适"的求解器设置问题。一定有某种单调累积的东西。
机制通常是这样:退化子模型会改变方程所在的几何。SEI 生长消耗孔隙率,析锂和活性物质损失挪动可用的化学计量窗口。每一圈模型都离边界更近一点,最终一个原本只是刚性的状态变成了奇异的。
这对调试有两个直接推论:
- 用加速退化来更快复现,而不是跑更久。把 SEI 速率常数调高,直到同样的失败出现在第 20 圈,然后在那里诊断。二十圈的复现是你可以反复迭代的。
- 报告失败时一定要带上圈数。"参数集 X 下第 500 圈中的第 312 圈失败"是可诊断的,"模拟崩了"不是。
import pybamm
# 用加速退化把 300 圈才出现的失败压到 20 圈内复现 ——
# 目的不是得到有物理意义的结果,而是把迭代周期从几小时缩到几分钟
fast = params.copy()
fast["SEI kinetic rate constant [m.s-1]"] *= 50
sim = pybamm.Simulation(
pybamm.lithium_ion.DFN(options={"SEI": "solvent-diffusion limited"}),
parameter_values=fast,
experiment=pybamm.Experiment(
[("Discharge at 1C until 3.0V", "Charge at 1C until 4.2V", "Hold at 4.2V until C/50")] * 30
),
)
try:
sol = sim.solve()
print(f"跑完了 {len(sol.cycles)} 圈 —— 把倍数再调大")
except Exception as exc:
print(f"复现成功: {type(exc).__name__}")
六、按这个顺序排查
- 记录失败位置。圈数、圈内哪一步、最后一个成功的时间点。
- 换 SPMe 重跑。如果同样失败,停止调求解器——问题在参数或协议。
- 检查有界变量,在能成功的最长那次运行上看。找化学计量数贴近 0 或 1、电解液浓度塌陷、孔隙率趋零。
- 把实验协议和电芯对一遍。退化后的电芯已经达不到的截止电压、已经撑不住的倍率,产生的正是这种失败。
- 到这一步才轮到选项。先
max_nonlinear_iterations,再max_order_bdf,而且在相信它生效之前先断言convert_to_format == "casadi"。 - 放宽过任何东西之后都要重新验证结果。靠放松接受阈值才跑完的运行,不等于可信的运行;拿一小段和更严格的设置对照一次。
七、跑通之后该记录什么
一次花掉一整天诊断的收敛失败,半年后会在另一组参数上重演。让第二次变便宜的是记录:
模型: DFN, options {SEI: solvent-diffusion limited, ...}
参数: OKane2022, 修改: SEI kinetic rate constant = 1e-15
实验: 1C/1C CCCV, 请求 500 圈
失败: 第 312 圈, 放电步, t = ...
诊断: 负极颗粒表面化学计量数 -> 0.9993
根因: 容量衰减约 30% 后截止电压已不可达
修法: 截止电压改到 2.8V;不是求解器改动
求解器: IDAKLU, rtol 1e-4, atol 1e-6, 其余默认
注意"不是求解器改动"这一行。这份记录一半的价值在于拦住下一个人——通常就是你自己——再去动容差。
References
- PyBaMM IDAKLUSolver API 参考
- PyBaMM 讨论:corrector convergence failed repeatedly or with |h| = hmin
- PyBaMM 讨论:understanding solver doesn't converge
- PyBaMM 耦合退化 notebook
如果排查后发现是参数本身有问题而不是求解器,下一步见PyBaMM 参数拟合与可辨识性。
还可以参考:PyBaMM 架构与求解器选型(SPM/SPMe/DFN 的取舍,以及 IDAKLU 为什么适合长时间退化模拟)。
