Training a Battery AI Model with PyBaMM: Predicting SOH and RUL
Training a Battery AI Model with PyBaMM: Predicting SOH and RUL
Search
Ask the AI

Training a Battery AI Model with PyBaMM: Predicting SOH and RUL

The previous article built the full pipeline for generating physics-labelled EIS data with PyBaMM. This one moves to the next step: training sequence models (LSTM, Transformer) to predict state of health (SOH) and remaining useful life (RUL).

One thing has to be said first. SOH and RUL are tasks of completely different difficulty, and treating them as one multi-task problem is the most common form of self-deception in this field. SOH estimates the present state, and the information for it is already in the input. RUL extrapolates the future, and early in a cell’s life it is fundamentally ill-posed. The next sections explain why, and what to do about it.

SOH and RUL prediction results with feature importance
Transformer inference: SOH tracking, RUL boundary prediction, and local gradient attribution.

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

1. Degradation as a stochastic process

Battery ageing is non-Markovian: internal state at cycle $k$ depends on the entire stress history. Under optimal estimation, SOH and RUL are hidden states of a discrete-time nonlinear system. The classical formulation is an extended Kalman filter:

State transition (SEI growth and loss of active material):

$$ x_{k+1} = f(x_k, u_k) + w_k, \quad w_k \sim \mathcal{N}(0, Q) $$

Observation (EIS spectra and terminal voltage):

$$ y_k = h(x_k, u_k) + v_k, \quad v_k \sim \mathcal{N}(0, R) $$

$x_k$ holds the internal capacity parameters that map directly to SOH (LLI, LAM). The deep learning task is to replace the heuristic observation function $h(\cdot)$ with a differentiable network $\theta$ that maximises the joint log-likelihood of the degradation trajectory.

There is an under-appreciated corollary here: a network fed only the current cycle’s EIS is structurally incapable of expressing non-Markovian behaviour. It needs a history window, or no amount of depth turns it into more than a noisy lookup table.

2. Why RUL is ill-posed early in life

Take two cells whose capacity curves are nearly identical for the first 200 cycles. One hits accelerated lithium plating — a knee point — at cycle 400; the other fades smoothly to 800. At cycle 100 their observables are almost indistinguishable while their true RUL differs by a factor of two.

This is not a modelling capacity problem. The information is simply not present in the input. Force the network to emit a point estimate anyway and it regresses to the conditional mean of the training set — which shows up early in life as predictions hugging the dataset’s average lifetime. The metric looks acceptable; the discriminative power is nil.

Three responses, in order of preference:

Approach Output Cost
Quantile regression RUL at the 10/50/90th percentile; intervals widen naturally early on Swap in pinball loss — essentially free
Classify into lifetime bands “more than 500 / 200-500 / under 200 cycles left” Loses resolution, but aligns with maintenance decisions
Only predict RUL below an SOH threshold Restricted to the regime where information exists No early output, which the application may not accept

What to avoid is plain MSE point regression on RUL reported as a single average error — that number hides complete early-life failure.

3. A model definition that actually runs

The following is self-contained. Positional encoding has to be written out — PyTorch ships no PositionalEncoding class, and a great many tutorials reference one that does not exist.

import math
import torch
import torch.nn as nn

class PositionalEncoding(nn.Module):
    """Standard sinusoidal encoding. "Position" here is cycle index, not
    wall-clock time - which only works for evenly spaced cycles. If your EIS
    is measured at irregular intervals, feed delta-cycle as a feature instead
    of relying on positional encoding."""

    def __init__(self, d_model, max_len=5000):
        super().__init__()
        pe = torch.zeros(max_len, d_model)
        pos = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
        div = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
        pe[:, 0::2] = torch.sin(pos * div)
        pe[:, 1::2] = torch.cos(pos * div)
        self.register_buffer("pe", pe.unsqueeze(0))   # buffer, not parameter: no
                                                      # gradient, but saved with the model

    def forward(self, x):
        return x + self.pe[:, : x.size(1)]


class ImpedanceTransformer(nn.Module):
    def __init__(self, input_dim=120, d_model=256, nhead=8, num_layers=4, n_quantiles=3):
        super().__init__()
        self.input_projection = nn.Linear(input_dim, d_model)
        self.pos_encoder = PositionalEncoding(d_model)

        layer = nn.TransformerEncoderLayer(
            d_model=d_model, nhead=nhead,
            dim_feedforward=4 * d_model,
            batch_first=True,        # without this the convention is [seq, batch, dim]
                                     # and silently misaligns with the [batch, seq, dim] below
            norm_first=True,         # pre-LN, far more stable when deep
        )
        self.transformer = nn.TransformerEncoder(layer, num_layers=num_layers)

        self.soh_head = nn.Linear(d_model, 1)
        # RUL emits quantiles rather than a point estimate - see the previous section
        self.rul_head = nn.Sequential(
            nn.Linear(d_model, 64), nn.GELU(), nn.Linear(64, n_quantiles), nn.Softplus()
        )

    def forward(self, eis_sequence, pad_mask=None):
        # eis_sequence: [batch, seq_len, input_dim]
        # pad_mask:     [batch, seq_len], True marks padding
        x = self.pos_encoder(self.input_projection(eis_sequence))
        feats = self.transformer(x, src_key_padding_mask=pad_mask)

        if pad_mask is None:
            latest = feats[:, -1, :]
        else:
            # For variable-length sequences [-1] is padding. Take the last valid step.
            lengths = (~pad_mask).sum(dim=1) - 1
            latest = feats[torch.arange(feats.size(0)), lengths]

        # Quantiles must be monotone: cumulative sum guarantees q10 <= q50 <= q90
        rul = torch.cumsum(self.rul_head(latest), dim=-1)
        return self.soh_head(latest), rul

Three silent failure modes are flagged in the comments: omitting batch_first misaligns the dimension convention without raising anything; taking [:, -1, :] on padded sequences reads padding; and unconstrained quantile heads happily produce q90 below q10.

4. Loss: put the physics in, do not check it afterwards

$$ \mathcal{L}(\theta) = \lambda_1 \| \text{SOH}_{pred} - \text{SOH}_{true} \|_2^2 + \lambda_2 \mathcal{L}_{pinball}(\text{RUL}) + \lambda_3 \Phi(x) $$

import torch
import torch.nn.functional as F

QUANTILES = torch.tensor([0.1, 0.5, 0.9])

def pinball_loss(pred, target, quantiles=QUANTILES):
    """pred: [batch, n_q]  target: [batch, 1]"""
    q = quantiles.to(pred.device).view(1, -1)
    err = target - pred
    return torch.maximum(q * err, (q - 1) * err).mean()

def monotonicity_penalty(soh_seq):
    """SOH must be non-increasing over cycles. Rest recovery causes small genuine
    rebounds, so allow a tolerance band and only penalise rises beyond it - a hard
    constraint would suppress real physics."""
    diff = soh_seq[:, 1:] - soh_seq[:, :-1]
    return F.relu(diff - 0.002).pow(2).mean()

def total_loss(soh_pred, rul_pred, soh_true, rul_true, soh_seq,
               l1=1.0, l2=0.5, l3=0.1):
    return (l1 * F.mse_loss(soh_pred, soh_true)
            + l2 * pinball_loss(rul_pred, rul_true)
            + l3 * monotonicity_penalty(soh_seq))

The penalty uses relu(diff - 0.002) rather than relu(diff) because post-rest capacity recovery is real. Treating it as a violation teaches the model an over-smoothed curve that goes blunt exactly where the knee is.

5. Covariate shift and leakage

The deadliest mistake with synthetic data is a row-wise random split. Time series derived from a single Simulation.solve() trajectory have fully deterministic covariance; if cycle $N$ and $N+1$ of the same cell land on opposite sides of the split, the Transformer can cheat by interpolation and then fail catastrophically on real hardware.

import numpy as np
from sklearn.model_selection import GroupKFold

groups = metadata["cell_design_id"].values
gkf = GroupKFold(n_splits=5)

for fold, (tr, te) in enumerate(gkf.split(X, y, groups=groups)):
    # Assert in the pipeline. Discovering implausibly good metrics later costs more.
    assert not (set(groups[tr]) & set(groups[te])), f"group leak in fold {fold}"
    ...

There is a subtler leak: feature normalisation. Computing mean and standard deviation over the full dataset before splitting bleeds test statistics into training. Normalisation parameters must come from the training fold alone, then be applied to the test fold.

6. Evaluation: one average error will lie to you

What makes battery life prediction hard is early-life uncertainty, error near the knee, and cross-cell generalisation. Reporting only RUL MAE conceals all three.

Metric What it examines Why it matters
SOH MAE / RMSE Continuous health-state error Overall capacity tracking accuracy
RUL error by life stage Early / mid / late reported separately A single average lets strong late-life numbers mask early-life failure
Quantile coverage Fraction of truths inside [q10, q90] Should sit near 80%; well below means uncertainty is understated
Knee point error Displacement of the fade inflection Determines whether the maintenance window is trustworthy
Group split gap Within-cell vs across-cell test performance A large gap indicates trajectory leakage or fake generalisation
Monotonicity violation Implausible SOH rebounds Checks the model against basic degradation physics

Quantile coverage deserves particular attention. A model with excellent RUL MAE whose [q10, q90] interval covers only 45% of truths is more dangerous in production than one with twice the error and honest intervals — it invites you to trust it at exactly the wrong moment.

7. Deploying to a BMS: quantisation is not free

A real BMS runs on an ASIL-rated automotive MCU below 100 MHz. A multi-head attention model trained in PyTorch has to be compressed hard: post-training quantisation (PTQ) to int8 plus structured pruning, exported through ONNX, running alongside the hardware EKF matrix unit.

But int8 PTQ carries a trap that must be validated for. We measured this on a separate project: an int8 model that was essentially lossless on the standard validation set collapsed on out-of-distribution samples. Validating quantisation only on well-behaved data badly overstates how safe it is.

Mapped onto batteries, quantisation validation has to cover these regions separately:

  • Samples near the knee — the steepest gradients, where quantisation error is amplified most
  • Low-temperature operation — spectra differ markedly from room temperature and activation distributions shift
  • Degradation pathways rare in the training set, such as plating-dominated rather than SEI-dominated cells

The calibration set must include these regions; otherwise PTQ fits its dynamic range to the common cases and the rare ones saturate outright. If int8 proves unacceptable there, fall back to fp16 — half the size benefit for predictable behaviour is usually the right trade.

8. Experiment record template

Data source:    PyBaMM DFN/SPMe + measured calibration set
Parameter set:  OKane2022 (degradation) / self-fitted
Split:          GroupKFold by cell_design_id / protocol_id
Normalisation:  computed on training fold only, applied to test fold
Input window:   last N EIS spectra + temperature + DOD + C-rate
Targets:        SOH (point), RUL (q10/q50/q90)
Key metrics:    SOH MAE, stage-wise RUL MAE, quantile coverage, knee point error
Leak check:     assert no group overlap between train and test
Quant check:    knee / low-temp / rare pathway reported separately
Deployment:     ONNX / int8 or fp16 / MCU latency budget

This record is what connects physical modelling, machine learning evaluation and embedded constraints. Without it, a model scoring well on a random split has most likely memorised synthetic trajectories rather than learned transferable ageing behaviour.

References

Leave a Reply

Scroll down