Metadata-Version: 2.4
Name: battery-cycle-life-analyzer
Version: 0.3.0
Summary: Fit battery capacity-fade models and project bounded end of life
Author-email: Mohammad Rezwan Khan <mohammadrezwankhan@users.noreply.github.com>
License-Expression: MIT
Project-URL: Homepage, https://mohammadrezwankhan.github.io/battery-cycle-life-analyzer/
Project-URL: Source, https://github.com/mohammadrezwankhan/battery-cycle-life-analyzer
Project-URL: Issues, https://github.com/mohammadrezwankhan/battery-cycle-life-analyzer/issues
Project-URL: Documentation, https://github.com/mohammadrezwankhan/battery-cycle-life-analyzer#readme
Project-URL: Changelog, https://github.com/mohammadrezwankhan/battery-cycle-life-analyzer/blob/main/CHANGELOG.md
Keywords: battery,battery-degradation,cycle-life,remaining-useful-life,curve-fitting,energy-storage
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.21
Requires-Dist: scipy>=1.7
Requires-Dist: matplotlib>=3.5
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Dynamic: license-file

# Battery Cycle‑Life Analyzer (bcla)

[![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue)](https://python.org)
[![Tests](https://github.com/mohammadrezwankhan/battery-cycle-life-analyzer/actions/workflows/tests.yml/badge.svg)](https://github.com/mohammadrezwankhan/battery-cycle-life-analyzer/actions/workflows/tests.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow)](LICENSE)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/mohammadrezwankhan/battery-cycle-life-analyzer/blob/main/notebooks/demo.ipynb)

**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.

![Linear, power-law, and logarithmic fits to a synthetic NMC demonstration](docs/model_comparison_preview.png)

*Synthetic-data illustration, not a measured-cell lifetime forecast.*
Start with the [runnable CSV example](#quick-start), use the
[Colab notebook](https://colab.research.google.com/github/mohammadrezwankhan/battery-cycle-life-analyzer/blob/main/notebooks/demo.ipynb),
or inspect the [Oxford measured-current example](docs/oxford-energy-trading.md).

## Analysis workflow

`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.

## Documentation Map

- [Analysis workflow](#analysis-workflow) for the model-selection and uncertainty path.
- [Installation](#installation) and [Quick start](#quick-start) for the shortest runnable path.
- [Long-form data schema and provenance](#long-form-data-schema-v1--optional-duty-cycle-history-v2) for provenance-aware inputs.
- [Oxford grid-battery example](docs/oxford-energy-trading.md) for an opt-in,
  licensed real-data workflow on measured discharge EFC.
- [Assumptions and limitations](#assumptions-and-limitations) before interpreting EOL projections.
- [Project structure](#project-structure) for notebooks, package code, data, and tests.
- [Contributing](CONTRIBUTING.md) and [citation metadata](CITATION.cff) for shared or published work.

---

## Installation

Requires Python 3.9 or newer. Runtime dependencies are NumPy, SciPy, and
Matplotlib; `pip` installs them automatically.

```bash
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:

```bash
python -m pip install -e ".[dev]"
python -m pytest
```

## Quick start

After installation, run the bundled single-cell CSV example from the repository
root:

```bash
python -m bcla --csv data/quickstart.csv --model all
```

The 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`](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`:

```bash
python -m bcla --model all --bootstrap-samples 500
```

The [interactive notebook](notebooks/demo.ipynb) walks through the Python API.
For your own single-cell CSV/TSV file, replace the example path:

```bash
python -m bcla --csv path/to/your_cell.csv --model all
python -m bcla --csv path/to/your_cell.tsv --model all
```

These two paths are placeholders for your files. Keep multi-cell data separate:
use the [long-form loader](#long-form-data-schema-v1--optional-duty-cycle-history-v2)
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:

```bash
python -m bcla --csv data/raw.txt --cycle-col n --capacity-col q --sep '\t' --no-normalize
```

## Long-form data schema (v1 + optional duty-cycle history v2)

For provenance-aware workflows, use
`load_cycle_data_long_form()` to keep protocol metadata alongside `(cycle, capacity)`:

```python
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_id`
- `cycle`
- `capacity`

Optional experimental context columns:

- `chemistry`
- `timestamp_iso` *(RFC 3339 / ISO 8601 string, optional)*
- `temperature_c`
- `c_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``)*
- `protocol`
- `source`

### Optional duty-cycle history (v2)

If you need per-interval context beyond `cycles_per_day`, provide an optional
history file:

```python
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:

- `cycle` and `capacity` must parse as finite numbers
- `cycle` and numeric optional fields (when provided) must be within their
  documented bounds:
  - `depth_of_discharge`: `0 <= value <= 1`
  - `c_rate`, `rest_time_h`, `cycles_per_day`, `energy_throughput_wh`:
    non-negative
- `cell_id`, `cycle`, `capacity` cannot be missing
- per-cell summary envelopes (`validation_envelopes`) including:
  - observed `cycle_range` and `cycle_count`
  - `timestamp_range` and `timestamp_span_days` when timestamps are present
  - min/max for `temperature_c`, `c_rate`, `rest_time_h`, `cycles_per_day`,
    `depth_of_discharge`, and `energy_throughput_wh`

Returned fields:

- `rows`: full observation list with all preserved metadata
- `schema_version`: schema tag (default `"1"`)
- `validation_envelopes`: structured per-cell metadata envelope
- `duty_cycle_history_schema`: history schema tag when provided
- `duty_cycle_history`: parsed interval rows, or empty list
- `duty_cycle_history_validation`: per `(cell_id, experiment_id)` interval summary
- `cycles` / `capacity`: convenience aliases for single-cell files
- `for_cell(cell_id)`: per-cell arrays for fitting existing API

The empirical fitting API remains unchanged:

```python
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)
```

---

## Features

| 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 |

---

## Example

```python
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:

```python
interval = core.bootstrap_life_projection(
    best,
    current_cycle=750,
    samples=500,
    random_state=42,
)
```

---

## Real-data example

The opt-in [Oxford grid-battery walkthrough](docs/oxford-energy-trading.md)
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:

```bash
python examples/oxford_energy_trading.py --cell BMR_cell1
```

No 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.

---

## How the models are calculated

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:

```text
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:

```text
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.

### Assumptions and limitations

- 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.

---

## Project structure

```
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
```

---

## When to use this vs. PyBaMM

| Tool | Best for |
|------|----------|
| **bcla** (this) | Quick capacity‑fade curve‑fitting from cycling test data; EOL estimates; temperature‑sensitivity studies |
| [PyBaMM](https://github.com/pybamm-team/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.

---

## Contributing

Bug reports, focused feature proposals, documentation improvements, and
validation datasets with clear provenance are welcome. See
[CONTRIBUTING.md](CONTRIBUTING.md) for setup, testing, and pull-request
guidance.

For methodological questions, use
[GitHub Discussions](https://github.com/mohammadrezwankhan/battery-cycle-life-analyzer/discussions).
The [Oxford EFC convention discussion](https://github.com/mohammadrezwankhan/battery-cycle-life-analyzer/discussions/8)
collects feedback on comparing throughput across grid-service profiles.

---

## Citation

If you use this in published work, please cite the repository:

```bibtex
@software{khan2026bcla,
  author = {Mohammad Rezwan Khan},
  title = {Battery Cycle-Life Analyzer},
  year = {2026},
  url = {https://github.com/mohammadrezwankhan/battery-cycle-life-analyzer}
}
```

---

## License

[MIT](LICENSE)
