Fit degradation models to battery cycling data, project remaining useful life, and generate publication‑ready figures — in under 20 lines of Python.
Designed for battery researchers, energy‑storage engineers, and students who need a quick, transparent estimate of cycle‑life without running complex physics simulations.
data → chronological holdout → model selection → full-data refit → bounded EOL/RUL → bootstrap interval
The default multi-model workflow fits on earlier observations and selects the model that predicts the latest held-out observations best. It then refits that model on all observations. Residual-bootstrap intervals expose fit variability while retaining an explicit bound at three times the largest observed cycle.
- Analysis workflow for the model-selection and uncertainty path.
- Installation and Quick start for the shortest runnable path.
- Long-form data schema and provenance for provenance-aware inputs.
- Assumptions and limitations before interpreting EOL projections.
- Project structure for notebooks, package code, data, and tests.
- Contributing and citation metadata for shared or published work.
Requires Python 3.9 or newer. Runtime dependencies are NumPy, SciPy, and
Matplotlib; pip installs them automatically.
git clone https://github.com/mohammadrezwankhan/battery-cycle-life-analyzer.git
cd battery-cycle-life-analyzer
python -m pip install .For an editable development installation with pytest:
python -m pip install -e ".[dev]"
python -m pytestpython -m bcla --model all
python -m bcla --model all --bootstrap-samples 500Or open notebooks/demo.ipynb for an interactive walk‑through.
You can also point the CLI at a CSV/TSV file:
python -m bcla --csv data/cycles.csv --model all
python -m bcla --csv data/cycles.tsv --model allExpected CSV/TSV columns (case sensitive):
cycle: cycle index (int/float)capacity: measured capacity (absolute values; normalized internally by default)
Files ending in .tsv or .tab use a tab delimiter automatically. CLI column
mapping and separators are customizable; \t is accepted as an escaped tab:
python -m bcla --csv data/raw.txt --cycle-col n --capacity-col q --sep '\t' --no-normalizeFor provenance-aware workflows, use
load_cycle_data_long_form() to keep protocol metadata alongside (cycle, capacity):
from bcla.datasets import load_cycle_data_long_form
dataset = load_cycle_data_long_form("data/cycle_metadata_example.csv")
for cell_id in dataset.cell_ids:
cycles, capacity = dataset.for_cell(cell_id)
print(cell_id, cycles[:3], capacity[:3])Required columns:
cell_idcyclecapacity
Optional experimental context columns:
chemistrytimestamp_iso(RFC 3339 / ISO 8601 string, optional)temperature_cc_rate(non-negative)rest_time_h(non-negative)cycles_per_day(non-negative)depth_of_discharge(non-negative, fraction 0–1)energy_throughput_wh(non-negative)duty_cycle_profile(optional free-form descriptor, e.g.continuous,1x daily,partial cycles)protocolsource
If you need per-interval context beyond cycles_per_day, provide an optional
history file:
from bcla.datasets import load_cycle_data_long_form
dataset = load_cycle_data_long_form(
"data/cycle_metadata_example.csv",
history_csv_path="data/cycle_duty_history_example.csv",
)History columns:
- Required:
cell_id,experiment_id,interval_start,interval_end - Optional:
direction,duty_cycle_profile,protocol,operating_state,temperature_c,c_rate,depth_of_discharge,energy_throughput_wh,source
interval_end must be after interval_start, and timestamps must parse as ISO 8601.
The parsed table is available on the returned object as
dataset.duty_cycle_history, with interval summaries in
dataset.duty_cycle_history_validation.
cycles_per_day is an aggregate summary for the exported trace segment and can be
non-integer (for example, 0.5 for one cycle every two days). If your lab workflow
explicitly alternates single and double cycles, keep the aggregate rate here and
capture the regime in protocol.
Every optional field is explicit: when a value is missing in a row, it is stored
as None rather than silently defaulted.
The loader validates:
cycleandcapacitymust parse as finite numberscycleand numeric optional fields (when provided) must be within their documented bounds:depth_of_discharge:0 <= value <= 1c_rate,rest_time_h,cycles_per_day,energy_throughput_wh: non-negative
cell_id,cycle,capacitycannot be missing- per-cell summary envelopes (
validation_envelopes) including:- observed
cycle_rangeandcycle_count timestamp_rangeandtimestamp_span_dayswhen timestamps are present- min/max for
temperature_c,c_rate,rest_time_h,cycles_per_day,depth_of_discharge, andenergy_throughput_wh
- observed
Returned fields:
rows: full observation list with all preserved metadataschema_version: schema tag (default"1")validation_envelopes: structured per-cell metadata envelopeduty_cycle_history_schema: history schema tag when providedduty_cycle_history: parsed interval rows, or empty listduty_cycle_history_validation: per(cell_id, experiment_id)interval summarycycles/capacity: convenience aliases for single-cell filesfor_cell(cell_id): per-cell arrays for fitting existing API
The empirical fitting API remains unchanged:
from bcla import core, datasets
dataset = datasets.load_cycle_data_long_form("data/cycle_metadata_example.csv")
cycles, capacity = dataset.for_cell("LFP-A")
results = core.fit_all_models(cycles, capacity)| Feature | Description |
|---|---|
| Degradation models | Linear, power‑law (LFP‑style), logarithmic — all with scipy curve_fit |
| Validation-based selection | select_model_by_validation() scores forward prediction on the latest observations before a full-data refit |
| EOL projection | FitResult.eol_cycle() estimates when capacity hits any threshold |
| EOL/RUL intervals | bootstrap_life_projection() reports residual-bootstrap bounds, censored replicates, and fit failures |
| Temperature acceleration | Arrhenius‑based arrhenius_acceleration_factor() to compare operating temperatures |
| Publication plots | Matplotlib figures with ready‑to‑save PNG output at 150+ DPI |
| Built‑in datasets | Synthetic LFP and NMC cycling data for instant demo |
| CSV/TSV import | Load external cycle-capacity tables with normalization and validation |
| CLI + Python API | Use from the terminal or import as a library |
from bcla import core, viz, datasets
# 1. Load data
cycles, capacity = datasets.synthetic_nmc(cycles=1000)
# Or load cycle-capacity data from a file
# cycles, capacity = datasets.load_cycle_data("data/cycles.csv")
# 2. Select on the latest 20% of observations, then refit on all data
selection = core.select_model_by_validation(
cycles,
capacity,
validation_fraction=0.2,
)
name, best = selection.model_name, selection.fit
print(f"Held-out RMSE: {selection.validation_score.rmse:.5f}")
# 3. Project end‑of‑life
eol = best.eol_cycle(eol_fraction=0.8)
print(f"Selected model: {name}")
print(f"Projected EOL: {eol:.0f} cycles" if eol is not None
else "EOL is outside the supported projection window")
# 4. Quantify residual/refit variation in bounded EOL and RUL
interval = core.bootstrap_life_projection(
best,
eol_fraction=0.8,
samples=500,
random_state=42,
)
if interval.eol_lower is not None:
print(f"95% EOL interval: {interval.eol_lower:.0f}–{interval.eol_upper:.0f}")
print(f"95% RUL interval: {interval.rul_lower:.0f}–{interval.rul_upper:.0f}")
else:
print(
"Interval unavailable: "
f"successful={interval.successful_samples}, "
f"censored={interval.censored_samples}, "
f"failed={interval.failed_samples}"
)
# 5. Plot all full-data fits for diagnosis
results = core.fit_all_models(cycles, capacity)
fig = viz.model_comparison(results)
fig.savefig("capacity_fade.png", dpi=150, bbox_inches="tight")The interval object uses the latest observed cycle by default. For a different
reporting point, pass current_cycle=...; RUL is then the non-negative
difference between each bootstrap EOL sample and that cycle:
interval = core.bootstrap_life_projection(
best,
current_cycle=750,
samples=500,
random_state=42,
)The input capacity should be normalized, so an undegraded cell is near
Q = 1. For cycle index n, the library fits three empirical models:
| Model | Capacity equation | Parameters |
|---|---|---|
| Linear | Q(n) = Q0 - k n |
Initial capacity Q0; fade rate k |
| Power law | Q(n) = Q0 - alpha n^beta |
Scale alpha; exponent beta |
| Logarithmic | Q(n) = Q0 - a ln(1 + b n) |
Scale a; rate parameter b |
scipy.optimize.curve_fit estimates the parameters with bounded nonlinear
least squares. The initial-capacity bound is 0.5 <= Q0 <= 1.5; all
degradation coefficients are constrained to non-negative values. The power-law
exponent is constrained to 0.1 <= beta <= 2.0.
For observations Q_i and fitted values Qhat_i, model quality is reported as:
RMSE = sqrt(mean((Q_i - Qhat_i)^2))
R^2 = 1 - sum((Q_i - Qhat_i)^2) / sum((Q_i - mean(Q))^2)
select_model_by_validation() sorts observations into chronological cycle
order, fits each model on the earlier observations, and compares predictions on
the latest held-out window. validation_fraction is a fraction of observation
rows, not a fraction of the cycle-number horizon; repeated cycle indices stay
on the same side of the split. After selection, it refits only the chosen model
on all observations.
best_model() remains available when an explicitly in-sample diagnostic is
needed, but lowest training RMSE is not presented as forecast validation.
eol_cycle(0.8) finds the first projected cycle where
Q(n) <= 0.8 Q0. To avoid presenting unbounded extrapolation as evidence, the
search stops at three times the largest observed cycle and returns None if
the threshold is not reached.
bootstrap_life_projection() resamples centered residuals, refits the chosen
model, and repeats the same bounded threshold search. It reports successful,
censored, and failed replicates. Percentile bounds are reported only when every
requested replicate fits and reaches EOL inside the supported horizon; any
right-censoring or fit failure makes the bounds unavailable. More distinct
observations than fitted parameters and positive residual variance are required.
Temperature comparisons use a relative Arrhenius acceleration factor:
AF = exp[(Ea / kB) (1 / Tref - 1 / T)]
where temperatures are in kelvin, Ea is activation energy in electron-volts,
and kB is the Boltzmann constant in eV/K. AF > 1 indicates faster
degradation than at the reference temperature.
- These are empirical curve fits, not electrochemical or safety models.
- The bundled LFP and NMC datasets are synthetic demonstrations.
- EOL projections are sensitive to data quality, model choice, and the extrapolation distance.
- Bootstrap intervals quantify residual/refit variation conditional on one selected model; they do not cover model-form error, protocol changes, unrecorded measurement uncertainty, serially correlated residuals, or heteroscedastic and cycle-dependent residual variance. Residual resampling assumes the fitted residuals are exchangeable.
- A single chronological holdout is transparent but less stable than repeated validation across independent cells or test campaigns.
- The Arrhenius utility compares temperature acceleration independently; it is not coupled to the fitted capacity-fade trajectory.
Use laboratory data representative of the cell, protocol, temperature, and operating window before drawing engineering conclusions.
battery-cycle-life-analyzer/
├── bcla/ # Python library
│ ├── __init__.py
│ ├── core.py # Degradation models & curve fitting
│ ├── viz.py # Matplotlib plotting helpers
│ ├── datasets.py # Synthetic data and CSV/TSV import
│ └── __main__.py # CLI entry point
├── notebooks/
│ └── demo.ipynb # Interactive Jupyter demo
├── tests/
│ ├── test_core.py # Model and projection tests
│ ├── test_datasets.py # Data-import tests
│ ├── test_notebook.py # Executable-notebook regression tests
│ └── test_cli.py # Legacy Windows terminal-output tests
├── .github/workflows/
│ └── tests.yml # CI across supported Python versions
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── pyproject.toml
├── Makefile
└── README.md
| Tool | Best for |
|---|---|
| bcla (this) | Quick capacity‑fade curve‑fitting from cycling test data; EOL estimates; temperature‑sensitivity studies |
| PyBaMM | Full physics‑based electrochemical battery simulation (DFN, SPMe, etc.) |
bcla is complementary — fit a model to PyBaMM output data, or use it standalone for lab‑test data.
Bug reports, focused feature proposals, documentation improvements, and validation datasets with clear provenance are welcome. See CONTRIBUTING.md for setup, testing, and pull-request guidance.
If you use this in published work, please cite the repository:
@software{khan2026bcla,
author = {Mohammad Rezwan Khan},
title = {Battery Cycle-Life Analyzer},
year = {2026},
url = {https://github.com/mohammadrezwankhan/battery-cycle-life-analyzer}
}