Physics‑Informed Neural Network for Single Particle Model SOC and SOH Prediction
Physics‑Informed Neural Network (PINN)
A Physics‑Informed Neural Network (PINN) is a neural network trained not only on data but also on physical laws, typically expressed as differential equations.
Instead of learning purely from data, the model is constrained by:
- Electrochemical battery physics (e.g., diffusion, degradation)
- Governing PDEs/ODEs (e.g., Fick’s law, SEI growth models)
- Boundary & initial conditions
- Conservation laws
This hybrid approach improves generalization, especially when data is sparse or noisy. This is strongly supported by recent research showing PINNs outperform purely data‑driven models in SOH/SOC estimation under limited data conditions
Physics Relevant to Lithium‑Ion Battery Degradation
PINNs embed physics from electrochemical models.
Single Particle Model (SPM)
Fick's diffusion in solid phase:
∂cs(r,t)/∂t = (Ds / r2) · ∂/∂r ( r2 · ∂cs(r,t)/∂r ), 0 < r < R
Boundary conditions:
At r = 0: ∂cs/∂r = 0
At r = R: -Ds · ∂cs/∂r = j(t)/F
Initial condition:
cs(r,0) = cs,0(r)
Terminal voltage (simplified SPM):
V(t) = Up(cs,psurf(t)) − Un(cs,nsurf(t)) − I(t) · Rtot
SEI Growth & Capacity Fade
SOH degradation is often modeled:
SEI layer growth
Loss of active lithium
Increase in internal resistance
Neural Network Model Design
Designing a PINN for SOH/SOC Prediction After 800 Cycles
Define the Inputs and Outputs
- Inputs
- Cycle index (0–100)
- Charge/discharge current profile
- Voltage curves
- Temperature
- Feature Engineering: Time‑series features (dV/dt, dQ/dV, etc.)
- Outputs
- SOC(t) — continuous prediction over cycle
- SOH(cycle) — capacity fade trajectory
Network Architecture
PINN Architecture
- Fully connected MLP
- 4–8 hidden layers
- 64–256 neurons per layer
- Activation: tanh (Because PDE residuals require smooth derivatives; tanh is differentiable and stable.)
- Output layer: linear
Automatic Differentiation
1. First-order time derivative:
∂cs(r,t) / ∂t = d cs(r,t) / d t
2. First-order spatial derivative:
∂cs(r,t) / ∂r = d cs(r,t) / d r
3. Second-order spatial derivative:
∂2cs(r,t) / ∂r2 = d2 cs(r,t) / d r2
4. Full diffusion PDE residual (PINN form):
Rdiff = ∂cs/∂t − (Ds / r2) · ∂/∂r ( r2 · ∂cs/∂r )
5. SEI growth ODE residual:
RSEI = dLSEI/dt − (kSEI / LSEI)
6. Voltage residual:
RV = Vpred(t) − Vmeasured(t)
Training Strategy
Sampling Strategy
Data points (from battery dataset)
Collocation points (random points where PDE must hold)
Boundary points (for BC/IC constraints)
Optimizers
Two‑stage training is standard:
Adam (fast convergence)
L‑BFGS (fine‑tuning, improves PDE satisfaction)
Loss Function Design
This PINNs use a composite loss
Total Loss:
L = Ldata + λphys Lphysics + λbc LBC
Data Loss:
Ldata = || SOH - SOHtrue ||2 + || SOC - SOCtrue ||2
Physics Loss:
Enforces PDE/ODE constraints:
- Fick’s diffusion equation residual
- SEI growth ODE residual
- Voltage equation residual
Boundary/Initial Condition Loss
Ensures physical consistency:
- SOC ∈ [0,1]
- Concentration boundary conditions at particle surface
- Voltage limits (2.5–4.2 V)
How PINNs Improve SOH/SOC Prediction After 800 Cycles
- Better Generalization PINNs can predict SOH/SOC even when:
- Voltage curves are missing
- Sensor data is sparse
- Cycles beyond training range (e.g., extrapolating to 700)
This is project demonstrated in recent works’ PINNs outperform data‑driven models under sparse sensor conditions.
- Physical Interpretability The model learns degradation consistent with:
SEI growth
Lithium loss
Diffusion limits
- Stability
- PINNs avoid unphysical predictions (e.g., SOC > 100%).
Below (Design Pattern) code based of PINNs:
import torch
import torch.nn as nn
import torch.autograd as autograd
# =========================
# 1. Base MLP for PINN
# =========================
class MLP(nn.Module):
def __init__(self, in_dim, out_dim, hidden_dim=128, n_layers=6):
super().__init__()
layers = []
layers.append(nn.Linear(in_dim, hidden_dim))
layers.append(nn.Tanh())
for _ in range(n_layers - 1):
layers.append(nn.Linear(hidden_dim, hidden_dim))
layers.append(nn.Tanh())
layers.append(nn.Linear(hidden_dim, out_dim))
self.net = nn.Sequential(*layers)
def forward(self, x):
return self.net(x)
# ==========================================
# 2. PINN for battery SPM + SOH/SOC
# ==========================================
class BatteryPINN(nn.Module):
"""
Inputs:
x = [t, r, I, T, cycle] (after feature engineering: new features)
Outputs:
c_s(r,t) : solid concentration
soh(t) : state of health
soc(t) : state of charge
"""
def __init__(self, in_dim=3, hidden_dim=128, n_layers=6):
super().__init__()
# Example: input = [t, r, I] -> in_dim=3
# output = [c_s, soh, soc] -> out_dim=3
self.model = MLP(in_dim=in_dim, out_dim=3,
hidden_dim=hidden_dim, n_layers=n_layers)
# Physical parameters (those values need to set based on measurement)
self.D_s = 1.0e-14 # diffusion coefficient
self.R_p = 1.0e-6 # particle radius
self.F = 96485.3329 # Faraday constant
self.a = 1.0e5 # specific surface area
self.L = 1.0e-4 # electrode thickness
def forward(self, t, r, I):
"""
t, r, I: tensors of shape (N, 1)
"""
x = torch.cat([t, r, I], dim=1)
out = self.model(x)
c_s = out[:, 0:1]
soh = out[:, 1:2]
soc = out[:, 2:3]
return c_s, soh, soc
# -----------------------------
# Automatic differentiation
# -----------------------------
def gradients(self, y, x):
"""dy/dx for scalar y and vector x."""
return autograd.grad(
y, x,
grad_outputs=torch.ones_like(y),
create_graph=True,
retain_graph=True
)[0]
# ======================================
# 3. Physics residuals for PINN loss
# ======================================
def diffusion_residual(self, t, r, I):
"""
Fick's diffusion PDE in spherical coordinates:
∂c_s/∂t - D_s/r^2 * ∂/∂r ( r^2 ∂c_s/∂r ) = 0
"""
t.requires_grad_(True)
r.requires_grad_(True)
c_s, _, _ = self.forward(t, r, I)
# First derivatives
dc_dt = self.gradients(c_s, t) # ∂c_s/∂t
dc_dr = self.gradients(c_s, r) # ∂c_s/∂r
# Second derivative term in spherical coordinates:
# (1/r^2) * d/dr ( r^2 * dc/dr )
r2_dc_dr = r**2 * dc_dr
d_r2_dc_dr_dr = self.gradients(r2_dc_dr, r)
diffusion_term = self.D_s / (r**2 + 1e-12) * d_r2_dc_dr_dr
R_diff = dc_dt - diffusion_term
return R_diff
def sei_residual(self, t, L_sei_pred):
"""
Example SEI growth ODE:
dL_SEI/dt - k_SEI / L_SEI = 0
Here L_sei_pred is a NN output or a function of SOH.
"""
k_sei = 1.0e-12 # example
t.requires_grad_(True)
dLdt = self.gradients(L_sei_pred, t)
R_sei = dLdt - k_sei / (L_sei_pred + 1e-12)
return R_sei
def bc_residual_center(self, t, I):
"""
Boundary condition at r = 0:
∂c_s/∂r |_{r=0} = 0
"""
r0 = torch.zeros_like(t)
r0.requires_grad_(True)
c_s, _, _ = self.forward(t, r0, I)
dc_dr_center = self.gradients(c_s, r0)
return dc_dr_center # should be ~0
def bc_residual_surface(self, t, I):
"""
Boundary condition at r = R_p:
-D_s * ∂c_s/∂r |_{r=R_p} = I / (F a L)
"""
rR = torch.ones_like(t) * self.R_p
rR.requires_grad_(True)
c_s, _, _ = self.forward(t, rR, I)
dc_dr_surface = self.gradients(c_s, rR)
flux_left = -self.D_s * dc_dr_surface
flux_right = I / (self.F * self.a * self.L + 1e-12)
R_bc_surface = flux_left - flux_right
return R_bc_surface
def ic_residual(self, t0, r, I, c_s_init):
"""
Initial condition:
c_s(r, 0) = c_s_init(r)
"""
t0_ = torch.zeros_like(t0)
c_s_pred, _, _ = self.forward(t0_, r, I)
R_ic = c_s_pred - c_s_init
return R_ic
def conservation_mass_residual(self, t, r_grid, I):
"""
Mass conservation (simplified discrete version):
d/dt ∫ c_s(r,t) 4π r^2 dr + 4π R_p^2 j(t) = 0
Here we approximate the integral over r_grid.
"""
# r_grid: (N_r, 1), t: (1,1) or (N_r,1) broadcastable
t_expanded = t.expand_as(r_grid)
c_s, _, _ = self.forward(t_expanded, r_grid, I.expand_as(r_grid))
# approximate integral with simple Riemann sum
# assume uniform spacing in r
dr = (r_grid[1] - r_grid[0]).abs()
integrand = 4.0 * torch.pi * (r_grid**2) * c_s
N_s = torch.sum(integrand * dr)
# dN_s/dt via autograd
t_single = t.clone().requires_grad_(True)
t_expanded2 = t_single.expand_as(r_grid)
c_s2, _, _ = self.forward(t_expanded2, r_grid, I.expand_as(r_grid))
integrand2 = 4.0 * torch.pi * (r_grid**2) * c_s2
N_s2 = torch.sum(integrand2 * dr)
dNs_dt = self.gradients(N_s2, t_single)
# surface flux term: 4π R_p^2 j(t) = 4π R_p^2 * I / (F a L)
j = I / (self.F * self.a * self.L + 1e-12)
flux_term = 4.0 * torch.pi * (self.R_p**2) * j
R_mass = dNs_dt + flux_term
return R_mass
# ==========================================
# 4. Example: building loss function
# ==========================================
def pinn_loss(model,
t_coll, r_coll, I_coll, # collocation points for PDE
t_bc, I_bc, # BC points
t_ic, r_ic, I_ic, c_s_init, # IC points
t_data, r_data, I_data, # data points
c_s_data, soh_data, soc_data,
r_grid_for_mass):
"""
Combine data loss + physics loss.
"""
# ----- Data loss -----
c_s_pred, soh_pred, soc_pred = model(t_data, r_data, I_data)
loss_data = torch.mean((c_s_pred - c_s_data)**2) \
+ torch.mean((soh_pred - soh_data)**2) \
+ torch.mean((soc_pred - soc_data)**2)
# ----- PDE residual (diffusion) -----
R_diff = model.diffusion_residual(t_coll, r_coll, I_coll)
loss_pde = torch.mean(R_diff**2)
# ----- BC residuals -----
R_bc_center = model.bc_residual_center(t_bc, I_bc)
R_bc_surface = model.bc_residual_surface(t_bc, I_bc)
loss_bc = torch.mean(R_bc_center**2) + torch.mean(R_bc_surface**2)
# ----- IC residual -----
R_ic = model.ic_residual(t_ic, r_ic, I_ic, c_s_init)
loss_ic = torch.mean(R_ic**2)
# ----- Mass conservation residual -----
R_mass = model.conservation_mass_residual(t_coll[:1], r_grid_for_mass, I_coll[:1])
loss_mass = torch.mean(R_mass**2)
# Weights (tune these)
λ_pde = 1.0
λ_bc = 1.0
λ_ic = 1.0
λ_mass = 0.1
loss_phys = λ_pde * loss_pde + λ_bc * loss_bc + λ_ic * loss_ic + λ_mass * loss_mass
loss_total = loss_data + loss_phys
return loss_total, {
"loss_data": loss_data.item(),
"loss_pde": loss_pde.item(),
"loss_bc": loss_bc.item(),
"loss_ic": loss_ic.item(),
"loss_mass": loss_mass.item()
}
# ==========================================
# 5. Draft sketch of training loop
# ==========================================
if __name__ == "__main__":
device = "cuda" if torch.cuda.is_available() else "cpu"
# ============================================================
# Builded tensors
# ============================================================
# 1. Collocation points (PDE)
N_coll = 5000
t_coll = torch.rand(N_coll, 1).to(device)
r_coll = torch.rand(N_coll, 1).to(device) * 1e-6
I_coll = (torch.rand(N_coll, 1) * 4 - 2).to(device)
# 2. Boundary condition points
N_bc = 1000
t_bc = torch.rand(N_bc, 1).to(device)
I_bc = (torch.rand(N_bc, 1) * 4 - 2).to(device)
# 3. Initial condition points
N_ic = 2000
t_ic = torch.zeros(N_ic, 1).to(device)
r_ic = torch.rand(N_ic, 1).to(device) * 1e-6
I_ic = torch.zeros(N_ic, 1).to(device)
c_s_init = torch.ones(N_ic, 1).to(device) * 20000.0 # uniform initial concentration
# 4. Data points (synthetic for now — replace with real dataset)
N_data = 1500
t_data = torch.rand(N_data, 1).to(device)
r_data = torch.rand(N_data, 1).to(device) * 1e-6
I_data = (torch.rand(N_data, 1) * 4 - 2).to(device)
# synthetic ground truth (replace with real battery data)
c_s_data = 20000 + 500 * torch.sin(5 * t_data)
soh_data = 1.0 - 0.0005 * t_data
soc_data = torch.sigmoid(5 * (t_data - 0.5))
# 5. r-grid for mass conservation integral
N_r = 200
r_grid_for_mass = torch.linspace(0, 1e-6, N_r).reshape(-1, 1).to(device)
# ============================================================
# Initialize model + optimizer
# ============================================================
model = BatteryPINN(in_dim=3).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
# ============================================================
# Training loop
# ============================================================
for epoch in range(3000):
optimizer.zero_grad()
loss = pinn_loss(
model,
t_coll, r_coll, I_coll,
t_bc, I_bc,
t_ic, r_ic, I_ic, c_s_init,
t_data, r_data, I_data,
c_s_data, soh_data, soc_data,
r_grid_for_mass
)
loss.backward()
optimizer.step()
if epoch % 200 == 0:
print(f"Epoch {epoch} | Loss = {loss.item():.4e}")
Note: Remember this code version is not final code version that used in production/paper
