Generate Battery Aging and EIS AI Datasets with PyBaMM
Generate Battery Aging and EIS AI Datasets with PyBaMM
Search
Ask the AI

Generate Battery Aging and EIS AI Datasets with PyBaMM

Generating a large, physics-labelled battery dataset with PyBaMM was never really about writing a for-loop. It is a numerical design-of-experiments problem: you have to control boundary conditions, the topology of the parameter space, thermal gradients, transient snapshots of the electrochemical state vector, the frequency grid for impedance, and the convergence tolerances of the algebraic solver. Lose any one of them and the network learns the simulator’s numerical bias rather than electrochemistry.

This article gives a pipeline that actually runs, plus the four failure modes that hand you garbage data without raising a single error. Everything produced here is a deterministic numerical solution of a PDE system — physics-based synthetic data, which has to be aligned to real cells through parameter identification before it means anything. Without that step it is a self-consistent hallucination.

Example SOH and local ECM resistance labels over cycle count
SOH fade trajectories and internal impedance evolution. These degradation dynamics only mean something once they are calibrated against real cycling data.

PyBaMM battery modelling series (4 parts): ① Architecture and solvers② EIS labels③ Dataset pipeline④ Training SOH and RUL. This is part 3.

1. Stating the dataset objective formally

A well-posed battery AI dataset is this set:

$$ \mathcal{D} = \{(\mathbf{x}_i, \mathbf{y}_i, \mathbf{m}_i)\}_{i=1}^N $$

Here $\mathbf{x}_i \in \mathbb{R}^p$ is the observable feature vector (voltage transients, complex impedance $Z(\omega)$, protocol descriptors) and $\mathbf{y}_i \in \mathbb{R}^q$ holds the internal, unobservable degradation quantities (SOH, loss of lithium inventory, loss of active material).

The third term is the one people underestimate. $\mathbf{m}_i$ is the full metadata graph: model structure assumptions, thermodynamic parameters, solver configuration. Without it there is no physical causality behind $f: \mathbf{x} \rightarrow \mathbf{y}$ — and six months later, a dataset whose model class, solver tolerance and parameter set are unknown is a dataset you throw away. Metadata is not good record-keeping. It is part of the dataset.

2. Pick the model first — it caps how much data you can generate

Most tutorials start with DFN and then hit a wall on sample count. PyBaMM’s three workhorse models differ by more than an order of magnitude in cost, and that choice sets the ceiling on your dataset size.

Model Physics included Relative cost When it is wrong
SPM Single particle, no electrolyte concentration gradient Lowest Above roughly 1C, electrolyte polarisation matters and voltage curves are systematically optimistic
SPMe Single particle plus electrolyte dynamics Moderate Not enough when you need reaction distribution through the electrode thickness
DFN Full Doyle-Fuller-Newman, spatially resolved electrodes Highest Rarely “wrong”, but solver failure rates climb noticeably under degradation

A practical route: sweep the parameter space with SPMe, then re-run the selected subset at high fidelity with DFN. SPMe keeps electrolyte polarisation — the dominant first-order effect — at a fraction of the cost, which is what you want for broad coverage; DFN is reserved for the region you actually care about. This only works if you record which model produced each sample, which brings you back to $\mathbf{m}_i$.

Cost ratios depend heavily on electrode discretisation, cycle count and solver. Do not copy timings from any blog, including this one; the script below prints its own elapsed time, and only a measurement on your machine counts.

3. Degradation dynamics and where the labels come from

Extracting a “label” means querying the time-integrated state of a specific degradation PDE. SEI thickness $L_{SEI}$, the main source of LLI, is typically modelled as:

$$ \frac{\partial L_{SEI}}{\partial t} = \frac{M_{SEI}}{\rho_{SEI} z F} j_{SEI} \exp\left( -\frac{E_a}{R T} \right) \exp\left( -\frac{\alpha F (\phi_s – \phi_e – U_{SEI})}{R T} \right) $$

  • SOH: discharge capacity of the current cycle over nominal, $Q_k / Q_0$.
  • RUL-to-80: cycles remaining until the state manifold intersects the $SOH = 0.8$ boundary.
  • LLI: accumulated time integral of parasitic side-reaction current, which directly suppresses coulombic efficiency.
  • LAM: governed by particle fracture mechanics, triggered when local stress $\sigma_{t,max}$ exceeds yield strength. Record the two electrodes separately — their rates and mechanisms differ, and collapsing them into one scalar destroys the most discriminative signal you have.
  • EIS feature vector: generated by perturbing and linearising the DFN Jacobian in the frequency domain, capturing the charge-transfer semicircle and the Warburg tail.

4. A pipeline that actually runs

The following is self-contained — copy it out and it runs, with no undefined variables. It does four things: assemble a cell with degradation submodels, cycle it, extract labels, and treat solver failure as a first-class outcome.

import time
import pybamm

# Degradation mechanisms must be declared when the model is constructed.
# Pushing SEI parameters into ParameterValues afterwards does NOT enable
# them - this is the most common "it ran but nothing degraded" mistake.
OPTIONS = {
    "SEI": "solvent-diffusion limited",
    "SEI porosity change": "true",
    "lithium plating": "partially reversible",
    "lithium plating porosity change": "true",
    "particle mechanics": ("swelling and cracking", "swelling only"),
    "SEI on cracks": "true",
    "loss of active material": "stress-driven",
    "calculate discharge energy": "true",
}

def run_one(cycles=100, model_cls=pybamm.lithium_ion.SPMe, seed_params=None):
    model = model_cls(options=OPTIONS)

    # OKane2022 is parameterised for degradation studies. With a set like
    # Chen2020, which has no degradation parameters, the SEI and plating
    # submodels fall back to defaults and the result is not physical.
    param = pybamm.ParameterValues("OKane2022")
    if seed_params:
        param.update(seed_params, check_already_exists=False)

    exp = pybamm.Experiment(
        [("Discharge at 1C until 2.5V", "Charge at 0.3C until 4.2V", "Hold at 4.2V until C/100")]
        * cycles
    )

    sim = pybamm.Simulation(
        model, parameter_values=param, experiment=exp,
        solver=pybamm.IDAKLUSolver(),   # much faster than default CasADi on DAEs
    )

    t0 = time.perf_counter()
    try:
        sol = sim.solve(calc_esoh=False)
    except pybamm.SolverError as exc:
        # Failed samples must be kept, not silently skipped. The boundary of
        # the usable parameter space is drawn precisely by these failures.
        return {"ok": False, "reason": str(exc)[:200], "seconds": time.perf_counter() - t0}

    elapsed = time.perf_counter() - t0
    sv = sol.summary_variables

    # summary_variables keys have been renamed across PyBaMM versions. Scripts
    # that hard-code them break on upgrade with a KeyError - or worse, silently
    # pick up None.
    def pick(*names):
        for n in names:
            if n in sv:
                return sv[n]
        raise KeyError(f"none of {names} present; available: {list(sv.keys())[:12]}")

    cap = pick("Capacity [A.h]")
    return {
        "ok": True,
        "seconds": elapsed,
        "cycles_completed": len(cap),
        "capacity": cap,
        "soh": [c / cap[0] for c in cap],
        "lli": pick("Loss of lithium inventory [%]"),
        "lam_neg": pick("Loss of active material in negative electrode [%]"),
        "lam_pos": pick("Loss of active material in positive electrode [%]"),
        # Metadata travels with the labels. Do not put it in a separate file.
        "meta": {
            "model": model_cls.__name__,
            "param_set": "OKane2022",
            "pybamm_version": pybamm.__version__,
            "options": OPTIONS,
        },
    }

if __name__ == "__main__":
    r = run_one(cycles=50)
    if r["ok"]:
        print(f"{r['cycles_completed']} cycles in {r['seconds']:.1f}s")
        print(f"final SOH={r['soh'][-1]:.4f}  LLI={r['lli'][-1]:.2f}%  "
              f"LAM(neg)={r['lam_neg'][-1]:.2f}%  LAM(pos)={r['lam_pos'][-1]:.2f}%")
    else:
        print(f"solver failed (this is also data): {r['reason']}")

For batch generation use the repository script, which wraps the same logic in multiprocessing and sampling:

cd pybamm-ai-data-lab
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

python src/run_all.py --samples 200 --workers 4 --seed 7 \
  --backend pybamm --output /tmp/pybamm-ai-dataset

Do not set --workers to your core count reflexively. Each PyBaMM process compiles its own CasADi/IDAKLU expressions and the memory footprint is significant; saturating the cores often buys you paging rather than throughput. Start at 4 and climb while watching memory.

5. Four failure modes that quietly produce garbage

This section is the most valuable part of the pipeline. None of the following raises an error. The script exits cleanly, the CSV is written, and the data is worthless.

1. Degradation was never actually enabled

Symptom: 500 cycles in, the SOH curve is nearly flat and still reads 0.999.

Cause: degradation submodels must be declared through options when the model is constructed. Building pybamm.lithium_ion.DFN() and then updating SEI parameters in ParameterValues does not switch SEI growth on — those parameters are silently ignored. Check it directly: print(model.options["SEI"]).

2. Parameter set does not match the mechanisms

Symptom: SOH collapses absurdly fast, or LLI climbs to physically impossible values.

Cause: using a set like Chen2020 that was never calibrated for degradation. Missing degradation parameters fall back to defaults, and a default is not a “reasonable value” — it is a value that lets the solver run. For degradation work use OKane2022, or a set you fitted yourself.

3. The experiment terminated early

Symptom: cycles_completed is far below what you requested, with no exception raised.

Cause: PyBaMM’s Experiment stops early when the cell has degraded past the point where a step’s termination condition can be met — for instance capacity has faded so far that discharge never reaches the 2.5 V cutoff. That is correct behaviour, but if you never check, you end up with ragged trajectories whose end-of-life distribution is systematically truncated. Always compare len(capacity) against the cycle count you asked for.

4. The EIS spectrum is a numerical artefact

Symptom: negative real parts on the Nyquist plot, or a physically impossible hook at high frequency.

Cause: linearising at a deeply degraded operating point leaves the Jacobian badly conditioned, and a sparse frequency grid produces interpolation ghosts. Test with the Kramers-Kronig relations — they must hold for any linear, causal, stable, time-invariant system, so a violation means the spectrum is not a valid impedance response at all. Flag these samples; they must not enter supervised training.

6. Parameter identification: aligning synthetic data to real cells

Before trusting synthetic data, the underlying parameter distribution has to be fitted to real measurements by minimising the residual between experimental voltage $V_{exp}$ and simulated voltage $V_{sim}(\mathbf{p})$:

$$ J(\mathbf{p}) = \sum_{k} w_k \left( V_{exp}(t_k) – V_{sim}(t_k; \mathbf{p}) \right)^2 $$

import numpy as np
import pybamm
import scipy.optimize as opt

model = pybamm.lithium_ion.SPMe()
base_param = pybamm.ParameterValues("Chen2020")

# V_exp / t_eval come from your cycler export, already aligned to one time grid
# t_eval: np.ndarray  shape (T,)
# V_exp:  np.ndarray  shape (T,)

KEYS = [
    "Negative electrode active material volume fraction",
    "Positive electrode active material volume fraction",
]
BOUNDS = [(0.4, 0.8), (0.4, 0.8)]

def objective(x):
    param = base_param.copy()          # copy, or iterations contaminate each other
    param.update(dict(zip(KEYS, x)))
    try:
        sim = pybamm.Simulation(model, parameter_values=param)
        sol = sim.solve(t_eval)
    except pybamm.SolverError:
        return 1e6                     # large penalty for non-convergent points;
                                       # raising here kills the whole optimisation
    V_sim = sol["Terminal voltage [V]"](t_eval)
    return float(np.sum((V_exp - V_sim) ** 2))

res = opt.minimize(
    objective,
    x0=[base_param[k] for k in KEYS],
    method="L-BFGS-B",
    bounds=BOUNDS,
)
print(res.x, res.fun)

Both traps are in the comments: ParameterValues must be copied before mutation or iterations pollute each other, and solver failure has to return a large penalty rather than raise, otherwise the optimiser dies at the first non-convergent point.

Skip this step and the model has learned the artificial distribution imposed by Latin hypercube sampling, not electrochemistry. For production work, PyBOP and pybamm-param already package the cost-function and optimiser combinations.

7. Orthogonal splits and leakage

If snapshots from one simulated ageing trajectory (cycles 10, 50, 100 under the same parameters) are randomly scattered across train and test, the model overfits the deterministic evolution of the ODE system. It is reciting that trajectory, not learning a generalisable degradation function.

  • Split boundaries must stay physically orthogonal: split by cell_design_id, or isolate random subsets of the kinetic parameter space.
  • EIS points are structurally constrained by Kramers-Kronig; adjacent frequencies within one spectrum must never straddle the train/test boundary.
  • Assert it after splitting: no cell_design_id in the test set may appear in training. Putting that assertion in the pipeline is far cheaper than discovering suspiciously good metrics later.

8. Quality gates for dataset generation

At scale, sample count matters far less than whether each batch clears the physical, numerical and machine-learning gates.

Gate What is checked Pass condition On failure
Parameter sampling Ranges, correlations, LHS coverage, share of pathological combinations Covers the main physical dimensions without producing non-physical states Narrow the bounds, or label the pathological region as OOD
Degradation active Whether final SOH, LLI and LAM moved from their initial values Measurable SOH fade, of a magnitude consistent with the protocol Check model.options against the parameter set
Solver convergence SUNDIALS/CasADi status, step count, tolerance, failure exceptions Retained samples carry a success flag and stable step statistics Drop failed samples but keep the failing parameters to map the boundary
Trajectory completeness Ratio of cycles_completed to requested cycles Early-terminated samples are explicitly flagged Group them separately; do not mix with complete trajectories
EIS sanity Nyquist shape, high-frequency intercept, Warburg tail, KK consistency Spectrum is consistent with model assumptions and frequency grid Flag as numerical artefact, exclude from supervised training
Split strategy Whether cell_design_id, protocol_id or parameter clusters leak across splits Test set comes from physically isolated designs or protocols Rebuild the split; never scatter one trajectory randomly

9. Closing the gap to real cells

Synthetic data is irreplaceable for pre-training, for validating active-learning acquisition functions, and for architecture ablations. Crossing the sim-to-real gap additionally needs:

  • Continuous calibration against differential voltage analysis (DVA) and incremental capacity analysis (ICA) from real cyclers. Both are unusually sensitive to electrode-level degradation, which makes them good probes for whether the model captured the right mechanism. To look at a curve before writing any code, the site’s dQ/dV incremental capacity analyser plots it in the browser.
  • Global sensitivity analysis (Sobol indices) to quantify which unobservable parameters dominate the generated voltage signature. When the dominant terms contradict physical intuition, the parameter bounds are usually wrong.
  • Strict recording of solver constraints. When SUNDIALS cannot meet a $10^{-6}$ absolute tolerance through an aggressive ageing step, those points must be flagged non-physical and discarded rather than fed to a model unexamined.

One last warning: the biggest risk in a synthetic dataset is not that it is inaccurate. It is that it is too self-consistent. Real cells have manufacturing spread, thermal non-uniformity and sensor noise; a PDE solution has none of these. Think hard about injecting noise matched to your measured distributions before training — otherwise the metrics that look excellent in simulation will collapse on real hardware.

References

Batch generation runs into solver non-convergence more than anything else. For telling apart “tune the solver” from “fix the protocol”, see PyBaMM solver convergence failures.

Leave a Reply

Scroll down