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.
Synthetic-data illustration, not a measured-cell lifetime forecast. Start with the runnable CSV example, use the Colab notebook, or inspect the Oxford measured-current example.
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.
- Oxford grid-battery example for an opt-in, licensed real-data workflow on measured discharge EFC.
- 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 pytestAfter installation, run the bundled single-cell CSV example from the repository root:
python -m bcla --csv data/quickstart.csv --model allThe terminal prints each model's diagnostics and a Selected model: line with
held-out RMSE. It saves bcla_demo.png in the current directory and prints
Saved bcla_demo.png; running it again replaces that output image.
data/quickstart.csv contains twelve illustrative cycle
and capacity-in-Ah observations for one synthetic cell, not laboratory data.
The loader normalizes capacity by its first observation. Model selection uses
the latest chronological holdout; the plotted full-data fits are diagnostics,
not a claim of independently validated lifetime prediction.
For residual-bootstrap diagnostics, add --bootstrap-samples 500. To use the
built-in synthetic LFP series instead of a file, omit --csv:
python -m bcla --model all --bootstrap-samples 500The interactive notebook walks through the Python API. For your own single-cell CSV/TSV file, replace the example path:
python -m bcla --csv path/to/your_cell.csv --model all
python -m bcla --csv path/to/your_cell.tsv --model allThese two paths are placeholders for your files. Keep multi-cell data separate: use the long-form loader and select one cell before fitting, rather than mixing cells through the CLI.
Expected 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 |
| Real-data example | Opt-in Oxford grid-battery workflow using measured discharge equivalent full cycles |
| 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/quickstart.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 opt-in Oxford grid-battery walkthrough downloads original ODbL-licensed current and capacity CSVs from the University of Oxford archive, converts measured positive-current throughput to discharge equivalent full cycles, and runs the same validation and bounded-projection workflow:
python examples/oxford_energy_trading.py --cell BMR_cell1No Oxford source or derived trajectory is bundled or relicensed by this MIT project. The example reports source timestamp repairs, the small unobserved near-zero-current tail, and unsupported or censored EOL outcomes explicitly.
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.
- The Oxford example is opt-in and downloads separately licensed source data; measured EFC remains approximate because current logging is irregular.
- 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
├── examples/
│ └── oxford_energy_trading.py # Opt-in measured-EFC real-data example
├── 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.
For methodological questions, use GitHub Discussions. The Oxford EFC convention discussion collects feedback on comparing throughput across grid-service profiles.
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}
}