PyBaMM’s Experiment API almost never raises. You describe a protocol in plain strings, it runs, and you get a solution back. That is its strength, and it is also why the mistakes below are expensive: each of them produces an experiment that finishes normally and returns the wrong thing. All results here were measured with PyBaMM 26.5.0 on the Chen2020 and OKane2022 parameter sets.
PyBaMM battery modelling series · Silent failures, measured (part 2 of 7): Previous: PyBaMM’s Built-in Parameter Sets, Measured: Four Ways They Fail Silently · Next: PyBaMM Thermal Models, Measured: Zero Heating, Missing Entropy and a Cold Start at 25 °C · Series hub: Data pipeline (4) + Silent failures, measured (7) · RSS feed.
1. A step given in amps stops at 24 hours
Here is the same slow discharge on a 5 A·h cell, written two ways:
| Step string | Ran for | End voltage | Discharged | Steps completed |
|---|---|---|---|---|
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 |
the 0.1 A step, then Rest, then Charge |
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 is C/50 on this cell. Written as a C-rate, the step runs to the voltage cut-off. Written in amps, it stops at exactly 24 hours with less than half the charge removed, and in a multi-step protocol every following step is skipped. sim.solve() returns normally. sol.termination reads 'final time', which is indistinguishable from a step that was supposed to end on time.
The source explains why. When a step has no duration, PyBaMM assigns a default one:
- C-rate steps:
2 / Chours — twice the time to pass the nominal capacity. - Everything else — current in amps, power, voltage holds: 24 hours.
This dates from PR #4239, the fix for issue #4224 (a 0.01C discharge stopping at 24 hours). The fix changed only C-rate steps to 2/C hours; the accompanying unit test still asserts that a current step defaults to 24 * 3600 seconds. So this is intended behaviour, and anyone who switches from C-rates to amps — for instance to avoid the nominal-capacity error described in the parameter-set article — walks straight into it at low currents.
The C-rate default is not bulletproof either, because it is computed from nominal capacity. Ramadass2004 really holds 1.775× its nominal capacity, so a C/20 discharge takes about 35.5 of its 40 allotted hours: 89% of the safety margin gone before anything else goes wrong.
The warning, and how to lose it
PyBaMM does notice. It writes “Experiment is infeasible: default duration (86400 seconds) was reached…” — through its own logger, at WARNING level. It is not a Python warning and not an exception. Batch scripts commonly quiet PyBaMM’s chatter with:
pybamm.set_logging_level("ERROR")
With that line the truncation still happens and the message disappears entirely. I checked both levels: at WARNING the message appears, at ERROR nothing does.
Fix: give every slow or open-ended step an explicit ceiling — "Discharge at 0.1 A for 60 hours or until 2.5 V" — and verify completion (section 6) instead of trusting that no exception means no problem.
2. A flat list is not a cycle
These two experiments contain exactly the same steps:
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) # a list of tuples: 12 cycles
pybamm.Experiment(list(steps) * 12) # a flat list: 48 "cycles"
A tuple groups its steps into one cycle. A flat list makes every string its own cycle. On OKane2022 with an SEI model, I ran both for 12 real cycles and fitted capacity against cycle number:
| Written as | Summary rows | Lithium inventory lost | Fitted fade per “cycle” |
|---|---|---|---|
| tuple | 12 | 0.0234% | −0.1357 mAh / cycle |
| flat list | 48 | 0.0234% | −0.0343 mAh / cycle |
The physics is identical — the same lithium inventory was lost. Only the x-axis changed, by a factor of four. Extrapolate the flat-list fit to “cycles until 80% capacity” and you overstate cycle life by about 4×. Nothing errors, and the capacity values themselves look perfectly reasonable, because PyBaMM’s Capacity [A.h] is computed from the cell state rather than counted per cycle.
I fell into this one myself while testing section 5: a four-step protocol printed only one step per “cycle”, because I had written it as a list.
Fix: wrap each cycle’s steps in a tuple, and check len(sol.cycles) against the number of cycles you meant.
3. save_at_cycles leaves holes
For long runs, sim.solve(save_at_cycles=5) keeps full solutions only for some cycles. With 12 cycles, the saved ones were 1, 5, 10 and 12 — the first and last are always kept. The rest are None, still sitting in the list:
len(sol.cycles) -> 12
sol.cycles[3]["Voltage [V]"] -> TypeError: 'NoneType' object is not subscriptable
Any loop that plots every cycle breaks here. The per-cycle summary variables are unaffected — all 12 rows are present — so use sol.summary_variables for per-cycle metrics and filter the full solutions with [c for c in sol.cycles if c is not None].
4. period does not make PyBaMM more accurate — but it can make you less accurate
A common reflex when a curve looks rough is to shrink the step’s period. For PyBaMM’s own results it changes nothing. A 1C discharge on Chen2020:
| period | Output points | Discharged | End time | Solve time |
|---|---|---|---|---|
| 1 second | 3596 | 4.991870 A·h | 3594.146 s | 0.36 s |
| 1 minute (default) | 61 | 4.991870 A·h | 3594.146 s | 0.25 s |
| 1 hour | 2 | 4.991870 A·h | 3594.146 s | 0.24 s |
Identical to six decimal places. The solver chooses its own internal steps and stops on the voltage event exactly; period only decides how many points you are given afterwards.
The trap is on your side of the line. If you compute something yourself from the output — integrating current for charge throughput, say — the resolution matters. For a constant-current, constant-voltage charge, where current decays during the hold:
| period | Points | PyBaMM’s charge | np.trapezoid on output |
Error |
|---|---|---|---|---|
| 10 seconds | 987 | 5.1217 A·h | 5.1219 A·h | +0.00% |
| 1 minute | 167 | 5.1217 A·h | 5.1221 A·h | +0.01% |
| 10 minutes | 19 | 5.1217 A·h | 5.1377 A·h | +0.31% |
| 30 minutes | 8 | 5.1217 A·h | 5.2798 A·h | +3.09% |
Fix: take cumulative quantities from PyBaMM’s variables (Discharge capacity [A.h], Throughput capacity [A.h]) rather than integrating the output yourself. Choose period for how the plot should look, not for accuracy.
5. initial_soc=1.0 is not 4.2 V
initial_soc is measured against the parameter set’s own voltage window. I read the open-circuit voltage one second into a rest at initial_soc=1.0:
| Set | Upper cut-off | Voltage at initial_soc=1.0 |
|---|---|---|
| Chen2020, OKane2022, Mohtat2020, Ai2020, Ecker2015 | 4.2 V | 4.2000 V |
| Marquis2019 | 4.1 V | 4.1000 V |
| Prada2013 (LFP) | 3.6 V | 3.6000 V |
| ORegan2022 | 4.4 V | 4.4000 V |
Loop one “charge to 4.2 V” protocol over several sets starting from initial_soc=1.0, and ORegan2022 starts 0.2 V higher than the others; its first discharge begins at 4.2466 V under load. That is not an overshoot — step terminations are exact, and every charge step in these tests ended at 4.2000 V — it is the starting point. Combined with the fact that experiment voltages silently override a set’s cut-offs (see the parameter-set article), comparisons across sets need the start state stated in volts, not in SOC.
6. Check that the experiment actually finished
Because the failure in section 1 comes back as a normal solution, check completion explicitly. A truncated experiment has fewer cycles than defined, or a last cycle with fewer steps:
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"
normal, 3 cycles (True, '3 of 3 cycles ran, last cycle complete')
0.1 A step, truncated (False, 'cycle 1 stopped after step 1 of 3')
12 cycles with save_at_cycles=5 (True, '12 of 12 cycles ran, last cycle complete')
truncated in cycle 2 of 3 (False, 'cycle 2 stopped after step 1 of 3')
capacity termination set (True, '3 of 3 cycles ran, last cycle complete')
An experiment with a termination= condition such as "80% capacity" is allowed to end early; the check only requires that its last cycle is complete. It does not catch the flat-list mistake in section 2 — that experiment really does complete, it just means something different from what you intended.
7. How this was measured
- PyBaMM 26.5.0, Python 3.13, Apple M3, default
IDAKLUSolver. - Sections 1, 3, 4 and 6 use Chen2020; section 2 uses OKane2022 with
{"SEI": "solvent-diffusion limited"}; section 5 uses the sets named. - Default durations are read from
pybamm/experiment/step/base_step.pyandsteps.pyin this version, and from the diff of PR #4239. - Logger behaviour was checked by attaching a handler to
pybamm.loggerand running the truncated case at WARNING and at ERROR.
References
- PyBaMM issue #4224: simulations with pybamm.Experiment terminate after 24 h
- PyBaMM PR #4239: default duration of 2/C for C-rate steps, and the infeasibility warning
- PyBaMM: simulating long experiments
- PyBaMM Experiment API
Related: PyBaMM’s built-in parameter sets, measured, on nominal capacity and voltage windows; PyBaMM solver convergence failures, for when the experiment does raise; and the aging dataset pipeline, where a truncated cycle becomes a mislabelled training sample. For experiments run at a set temperature, PyBaMM thermal model pitfalls covers why a lumped cell can start warm. If you schedule cycles with start_time, read the start_time box in PyBaMM SEI and lithium plating pitfalls: repeated identical steps silently lose their scheduled rests.
