PyBaMM 的 Experiment 接口几乎从不报错。用几句字符串描述一个充放电流程,它就能跑完,把解交给你。这是它的长处,也正因为这样,下面这几个错误代价很高:每一个都会让实验正常跑完,然后交回一个错误的结果。文中所有数字都是用 PyBaMM 26.5.0 在 Chen2020 和 OKane2022 参数集上实测的。
PyBaMM 电池建模系列 · 静默错误实测(第 2 / 7 篇):上一篇 PyBaMM 18 个内置参数集实测:四种静默出错的方式 · 下一篇 PyBaMM 热模型实测:产热为零、缺失的熵热与从 25 °C 开始的低温仿真 · 专题页:数据管线 4 篇 + 静默错误实测 7 篇 · RSS 订阅。
一、用安培写的步骤,到 24 小时就停
同一个慢速放电,在一颗 5 A·h 的电芯上用两种写法:
| 步骤字符串 | 实际运行 | 末电压 | 放出电量 | 完成步数 |
|---|---|---|---|---|
Discharge at C/50 until 2.5 V |
51.5 h | 2.500 V | 5.149 A·h | 1 / 1 |
Discharge at 0.1 A until 2.5 V |
24.0 h | 3.778 V | 2.400 A·h | 1 / 1 |
| 上面这步,接静置,再接充电 | 24.0 h | 3.778 V | 2.400 A·h | 1 / 3 |
Discharge at 0.1 A for 60 hours or until 2.5 V |
51.5 h | 2.500 V | 5.149 A·h | 1 / 1 |
在这颗电芯上 0.1 A 就是 C/50。写成倍率,步骤会一直放到截止电压;写成安培,它在正好 24 小时处停下,放出的电还不到一半,而在多步流程里,后面所有步骤都被跳过。sim.solve() 正常返回。sol.termination 显示 'final time'——和一个本来就该按时长结束的步骤一模一样。
源码里写得很清楚。步骤没有指定时长时,PyBaMM 会给一个默认值:
- 倍率步骤:
2 / C小时——按标称容量走完一遍所需时间的两倍。 - 其余一切——安培电流、功率、恒压保持:24 小时。
这个规则来自 PR #4239,它修的是 issue #4224(一个 0.01C 放电在 24 小时处停下)。那次修复只把倍率步骤改成了 2/C 小时;随附的单元测试至今仍断言电流步骤的默认时长是 24 * 3600 秒。所以这是有意为之。而任何从倍率改用安培的人——比如为了绕开参数集那篇文章里说的标称容量误差——一到小电流就会直接撞上。
倍率的默认值也不是万无一失,因为它按标称容量算。Ramadass2004 的实际容量是标称的 1.775 倍,一个 C/20 放电要用掉给它的 40 小时里的约 35.5 小时:还没出别的问题,89% 的安全余量就没了。
警告,以及它是怎么消失的
PyBaMM 其实察觉到了。它会写一句 “Experiment is infeasible: default duration (86400 seconds) was reached…”——通过它自己的 logger,级别是 WARNING。它不是 Python 的 warning,也不是异常。而批量跑仿真的脚本,常常会用这一行压掉 PyBaMM 的日志噪音:
pybamm.set_logging_level("ERROR")
加了这一行,截断照样发生,提示却彻底消失。我两个级别都测了:WARNING 下能看到那句话,ERROR 下什么都没有。
修法:给每个慢速或者没有明确终点的步骤写上时长上限——"Discharge at 0.1 A for 60 hours or until 2.5 V"——并且显式检查实验是否跑完(见第六节),而不是默认”没抛异常就没问题”。
二、平铺列表不是一个循环
下面两个实验包含的步骤完全相同:
steps = ("Discharge at 1C until 2.5 V", "Rest for 30 minutes",
"Charge at C/2 until 4.2 V", "Hold at 4.2 V until C/50")
pybamm.Experiment([steps] * 12) # 元组组成的列表:12 个循环
pybamm.Experiment(list(steps) * 12) # 平铺列表:48 个"循环"
元组会把其中的步骤归为一个循环;平铺列表则让每个字符串自成一个循环。我在带 SEI 模型的 OKane2022 上把两种写法都跑了 12 个真实循环,再拟合容量对循环号的斜率:
| 写法 | summary 行数 | 锂库存损失 | 拟合出的”每循环衰减” |
|---|---|---|---|
| 元组 | 12 | 0.0234% | −0.1357 mAh / 循环 |
| 平铺列表 | 48 | 0.0234% | −0.0343 mAh / 循环 |
物理过程一模一样——损失的锂库存完全相同。变的只是 x 轴,差了 4 倍。拿平铺写法的拟合去外推”衰减到 80% 要多少圈”,循环寿命会被高估约 4 倍。不会报错,容量数值本身看起来也完全正常,因为 PyBaMM 的 Capacity [A.h] 是从电池状态算出来的,而不是按循环计数的。
写这篇文章测第五节时,我自己就掉进去了:一个四步的流程每个”循环”只打印出一步,因为我写成了列表。
修法:每个循环的步骤用元组包起来,并核对 len(sol.cycles) 是否等于你想要的循环数。
三、save_at_cycles 会留下空洞
长时间运行时,sim.solve(save_at_cycles=5) 只保存部分循环的完整解。跑 12 个循环时,保存下来的是第 1、5、10、12 个——首尾总会保存。其余的是 None,但仍然占着列表里的位置:
len(sol.cycles) -> 12
sol.cycles[3]["Voltage [V]"] -> TypeError: 'NoneType' object is not subscriptable
任何”逐个循环画图”的循环都会在这里崩。按循环的 summary 变量不受影响——12 行都在——所以按循环的指标用 sol.summary_variables 取,完整解则用 [c for c in sol.cycles if c is not None] 过滤。
四、period 不会让 PyBaMM 更准,但可能让你更不准
曲线看起来毛糙时,常见的反应是把步骤的 period 调小。对 PyBaMM 自己算出的结果,这一点用都没有。Chen2020 上的 1C 放电:
| period | 输出点数 | 放出电量 | 终止时刻 | 求解耗时 |
|---|---|---|---|---|
| 1 秒 | 3596 | 4.991870 A·h | 3594.146 s | 0.36 s |
| 1 分钟(默认) | 61 | 4.991870 A·h | 3594.146 s | 0.25 s |
| 1 小时 | 2 | 4.991870 A·h | 3594.146 s | 0.24 s |
到小数点后 6 位都一样。求解器自己决定内部步长,并在电压事件上精确停下;period 只决定事后给你多少个点。
陷阱在你自己这边。如果你从输出里自己算东西——比如对电流积分求充入电量——分辨率就要紧了。以恒流再恒压的充电为例,恒压阶段电流一直在衰减:
| period | 点数 | PyBaMM 算的充入电量 | 对输出做 np.trapezoid |
误差 |
|---|---|---|---|---|
| 10 秒 | 987 | 5.1217 A·h | 5.1219 A·h | +0.00% |
| 1 分钟 | 167 | 5.1217 A·h | 5.1221 A·h | +0.01% |
| 10 分钟 | 19 | 5.1217 A·h | 5.1377 A·h | +0.31% |
| 30 分钟 | 8 | 5.1217 A·h | 5.2798 A·h | +3.09% |
修法:累计量直接取 PyBaMM 的变量(Discharge capacity [A.h]、Throughput capacity [A.h]),不要自己对输出积分。period 按图想画多细来选,而不是按精度选。
五、initial_soc=1.0 不等于 4.2 V
initial_soc 是相对参数集自己的电压窗口定义的。我在 initial_soc=1.0 下静置 1 秒读开路电压:
| 参数集 | 上限电压 | initial_soc=1.0 时的电压 |
|---|---|---|
| Chen2020、OKane2022、Mohtat2020、Ai2020、Ecker2015 | 4.2 V | 4.2000 V |
| Marquis2019 | 4.1 V | 4.1000 V |
| Prada2013(磷酸铁锂) | 3.6 V | 3.6000 V |
| ORegan2022 | 4.4 V | 4.4000 V |
把一套”充到 4.2 V”的流程从 initial_soc=1.0 开始套在几个参数集上,ORegan2022 的起点就比别人高 0.2 V;它的第一次放电在负载下从 4.2466 V 开始。这不是过冲——步骤终止是精确的,这些测试里每个充电步骤都正好停在 4.2000 V——它就是起点本身。再加上实验字符串会静默覆盖参数集的截止电压(见参数集那篇),跨参数集比较时,起始状态要用伏特写明,而不是用 SOC。
六、确认实验真的跑完了
第一节的失败是以一个正常的解返回的,所以要显式检查。被截断的实验,循环数会少于定义,或者最后一个循环的步数不足:
def experiment_completed(sol, experiment):
"""(ok, message): False when PyBaMM stopped the experiment early."""
n_run = len(sol.cycles)
n_expected = len(experiment.cycles)
steps_done = len(sol.cycles[-1].steps) # the last cycle is always saved
steps_expected = experiment.cycle_lengths[n_run - 1]
if steps_done < steps_expected:
return False, f"cycle {n_run} stopped after step {steps_done} of {steps_expected}"
if n_run < n_expected and not experiment.termination:
return False, f"only {n_run} of {n_expected} cycles ran"
return True, f"{n_run} of {n_expected} cycles ran, last cycle complete"
正常,3 个循环 (True, '3 of 3 cycles ran, last cycle complete')
0.1 A 步骤被截断 (False, 'cycle 1 stopped after step 1 of 3')
12 个循环 + save_at_cycles=5 (True, '12 of 12 cycles ran, last cycle complete')
在 3 个循环的第 2 个里被截断 (False, 'cycle 2 stopped after step 1 of 3')
设了容量终止条件 (True, '3 of 3 cycles ran, last cycle complete')
设了 termination=(比如 "80% capacity")的实验允许提前结束,这个检查只要求它的最后一个循环是完整的。它抓不到第二节的平铺列表问题——那个实验确实跑完了,只是意思和你想的不一样。
七、测量方式
- PyBaMM 26.5.0,Python 3.13,Apple M3,默认
IDAKLUSolver。 - 第一、三、四、六节用 Chen2020;第二节用带
{"SEI": "solvent-diffusion limited"}的 OKane2022;第五节用表中所列参数集。 - 默认时长取自这个版本的
pybamm/experiment/step/base_step.py和steps.py,以及 PR #4239 的改动。 - 日志行为的检查方式:给
pybamm.logger挂一个 handler,分别在 WARNING 和 ERROR 级别下跑被截断的那个例子。
References
- PyBaMM issue #4224:pybamm.Experiment 仿真在 24 小时后终止
- PyBaMM PR #4239:倍率步骤默认时长改为 2/C,并加入不可行警告
- PyBaMM:长时间实验仿真
- PyBaMM Experiment API
相关阅读:PyBaMM 18 个内置参数集实测——标称容量与电压窗口;PyBaMM 求解器收敛失败排查——实验真的报错时;老化数据集管线——一个被截断的循环在那里会变成一个标错的训练样本。在指定温度下跑实验的话,PyBaMM 热模型实测解释了为什么集总模型里的电池可能从温热状态开始。如果用 start_time 排循环,请看 PyBaMM SEI 与析锂实测里关于 start_time 的补充说明:重复出现的相同步骤会静默丢掉安排好的静置。
