Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
# Ragone plots

[![Tests](https://github.com/mmsg-warwick/ragone/actions/workflows/tests.yml/badge.svg)](https://github.com/mmsg-warwick/ragone/actions/workflows/tests.yml)

[![codecov](https://codecov.io/gh/mmsg-warwick/ragone/graph/badge.svg?token=uk6ryEFTRn)](https://codecov.io/gh/mmsg-warwick/ragone)

[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.20762920.svg)](https://doi.org/10.5281/zenodo.20762920)

This repository contains the code to generate Ragone plots and reproduce the results of the article:

Expand All @@ -25,6 +24,18 @@ To also install the optional test dependencies:
pip install -e ".[test]"
```

## Reproducing the results

In order to reproduce the results of the article, you need to run the scripts in the `scripts/` folder. The scripts are designed to be run in a specific order, and they will produce the data files and figures needed for the article:

1. Run `run_ageing.py` to produce the .pkl files in the `data/` folder. These files contain the solutions of the ageing simulations, which are needed to extract the Ragone curves at different states of health.
2. You can now simultaneosly run:
- `ragone_ageing.py` to produce the Ragone plots for a specific simulation at different cycle numbers (i.e. different states of health). This wil also compute the metrics that will be saved as .csv files in the `data/` folder, which are needed to run `plot_power_energy_fade.py`.
- `ragone_compare.py` to produce the Ragone plots for all the combinations of cycling mode (power or current) and direction (charge or discharge).
- `ragone_parameters.py` to produce the Ragone plots showing the effect of a single parameter.
- `rpt.py` to produce the reference performance test plots.
3. Run `plot_power_energy_fade.py` to produce the plots showing the normalised energy and power fade vs cycle number, comparing slow and fast charging.

## Repository structure

- `data/`: Contains the data files produced by the scripts. The .pkl files are the solutions of the ageing simulations, used later to extract the Ragone curves at different states of health, and they are needed to run the other scripts. The .csv files are the summary metrics extracted from the fits of the Ragone curves, and they are needed to run `plot_power_energy_fade.py`.
Expand Down
2 changes: 1 addition & 1 deletion ragone/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from pathlib import Path
from ragone.config import get_options, get_parameter_values, get_var_pts
from ragone.utils import get_options, get_parameter_values, get_var_pts
from ragone.simulation import RagoneSimulation
from ragone.solution import RagoneSolution
from ragone.plotting import RagonePlot
Expand Down
File renamed without changes.
22 changes: 11 additions & 11 deletions tests/integration/test_ragone_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
- RagoneSolution <-> RagonePlot
- RagoneSolution.fit_log() <-> RagonePlot(fit=True)
- RagoneSimulation.solve() -> RagoneSolution -> RagonePlot
- config helpers <-> RagoneSimulation
- utils helpers <-> RagoneSimulation

pybamm.Simulation is still mocked to keep the tests fast, but all ragone
modules run for real so that cross-module contracts are verified.
Expand Down Expand Up @@ -408,53 +408,53 @@ def _solve_side_effect(**kwargs):


# ---------------------------------------------------------------------------
# Group 4: config helpers <-> RagoneSimulation
# Group 4: utils helpers <-> RagoneSimulation
# ---------------------------------------------------------------------------


@pytest.mark.integration
class TestConfigIntegration:
"""Config helpers produce outputs that integrate with RagoneSimulation."""
class TestUtilsIntegration:
"""Utils helpers produce outputs that integrate with RagoneSimulation."""

def test_get_options_no_degradation_returns_empty_dict_and_empty_tag(self):
from ragone.config import get_options
from ragone.utils import get_options

options, tag = get_options()
assert options == {}
assert tag == ""

def test_get_options_sei_only(self):
from ragone.config import get_options
from ragone.utils import get_options

options, tag = get_options(SEI=True)
assert "SEI" in options
assert "SEI porosity change" in options
assert tag == "_SEI"

def test_get_options_plating_only(self):
from ragone.config import get_options
from ragone.utils import get_options

options, tag = get_options(plating=True)
assert "lithium plating" in options
assert "lithium plating porosity change" in options
assert tag == "_plating"

def test_get_options_lam_only(self):
from ragone.config import get_options
from ragone.utils import get_options

options, tag = get_options(lam=True)
assert "particle mechanics" in options
assert "loss of active material" in options
assert tag == "_lam"

def test_get_options_all_mechanisms_tag_order(self):
from ragone.config import get_options
from ragone.utils import get_options

_, tag = get_options(SEI=True, plating=True, lam=True)
assert tag == "_SEI_plating_lam"

def test_get_options_sei_lam_without_plating(self):
from ragone.config import get_options
from ragone.utils import get_options

options, tag = get_options(SEI=True, lam=True)
assert "SEI" in options
Expand All @@ -464,7 +464,7 @@ def test_get_options_sei_lam_without_plating(self):

def test_get_options_returns_dict_usable_by_ragone_simulation(self, mock_model):
"""Options dict from get_options() can be passed to RagoneSimulation."""
from ragone.config import get_options
from ragone.utils import get_options

options, _ = get_options()
# options is normally used for pybamm model construction, but the key
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/test_plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,28 @@ def test_no_labels_produces_no_legend(self, power_plot):
# Default (labels=None) → skip_legend=True → no legend drawn
_, ax = power_plot.plot(show_plot=False)
assert ax.get_legend() is None

@pytest.mark.unit
def test_linear_scale_with_labels_creates_legend(self, power_solution):
# Covers the `elif self.scale == "linear"` legend branch (line 315-316)
fig, ax = RagonePlot(power_solution, scale="linear", labels=["Series A"]).plot(
show_plot=False
)
assert ax.get_legend() is not None

@pytest.mark.unit
def test_both_volume_and_mass_produces_secondary_axes(self, power_solution):
# Covers the `if self.volume and self.mass` branch (lines 298-299)
fig, ax = RagonePlot(power_solution, volume=0.01, mass=0.05).plot(
show_plot=False
)
assert isinstance(fig, matplotlib.figure.Figure)
assert len(ax.child_axes) >= 2

@pytest.mark.unit
def test_current_solution_with_volume_secondary_axes(self, current_solution):
# Covers convert_labels "Capacity" branch (lines 219-220) and
# "Current" branch (lines 224-226) inside _set_secondary_axes
fig, ax = RagonePlot(current_solution, volume=0.001).plot(show_plot=False)
assert isinstance(fig, matplotlib.figure.Figure)
assert len(ax.child_axes) >= 2
87 changes: 87 additions & 0 deletions tests/unit/test_solution.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,90 @@ def test_plot_passes_kwargs_to_ragone_plot(self, power_solution):
MockPlot.assert_called_once_with(
power_solution, labels=["A"], volume=0.1, mass=0.2, scale="linear"
)


# ---------------------------------------------------------------------------
# fit() (requires setting solution.scale manually — not set by __init__)
# ---------------------------------------------------------------------------

# Synthetic data for the linear fit: _gaussian_linear(x, E0=10, P0=5, n=1)
_P_LINEAR = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
_E_LINEAR = 10.0 * np.exp(-((_P_LINEAR / 5.0) ** 1.0))


class TestRagoneSolutionFit:
@pytest.fixture
def loglog_solution(self):
data = {
"Power [W]": _P_FIT,
"Energy [W.h]": _E_FIT,
"Time [h]": _E_FIT / _P_FIT,
}
sol = RagoneSolution(data, "power")
sol.scale = "loglog"
return sol

@pytest.fixture
def linear_solution(self):
data = {
"Power [W]": _P_LINEAR,
"Energy [W.h]": _E_LINEAR,
"Time [h]": _E_LINEAR / _P_LINEAR,
}
sol = RagoneSolution(data, "power")
sol.scale = "linear"
return sol

@pytest.mark.unit
def test_fit_loglog_returns_three_params(self, loglog_solution):
popt = loglog_solution.fit()
assert len(popt) == 3

@pytest.mark.unit
def test_fit_loglog_sets_raw_metrics(self, loglog_solution):
popt = loglog_solution.fit()
np.testing.assert_array_equal(loglog_solution._raw_metrics, popt)

@pytest.mark.unit
def test_fit_loglog_metrics_has_fitting_scale_key(self, loglog_solution):
loglog_solution.fit()
assert loglog_solution.metrics["Fitting scale"] == "loglog"

@pytest.mark.unit
def test_fit_loglog_metrics_has_n_and_reference_keys(self, loglog_solution):
loglog_solution.fit()
assert "n" in loglog_solution.metrics
assert "Reference energy [W.h]" in loglog_solution.metrics
assert "Reference power [W]" in loglog_solution.metrics

@pytest.mark.unit
def test_fit_loglog_recovers_known_parameters(self, loglog_solution):
# Data generated with E0=1, P0=1, n=1
popt = loglog_solution.fit()
assert popt[0] == pytest.approx(1.0, rel=1e-3)
assert popt[1] == pytest.approx(1.0, rel=1e-3)
assert popt[2] == pytest.approx(1.0, rel=1e-3)

@pytest.mark.unit
def test_fit_linear_returns_three_params(self, linear_solution):
popt = linear_solution.fit()
assert len(popt) == 3

@pytest.mark.unit
def test_fit_linear_sets_raw_metrics(self, linear_solution):
popt = linear_solution.fit()
np.testing.assert_array_equal(linear_solution._raw_metrics, popt)

@pytest.mark.unit
def test_fit_linear_metrics_has_fitting_scale_key(self, linear_solution):
linear_solution.fit()
assert linear_solution.metrics["Fitting scale"] == "linear"

@pytest.mark.unit
def test_fit_linear_recovers_known_parameters(self, linear_solution):
# Data generated with E0=10, P0=5, n=1.
# _gaussian_linear returns E0 * exp(-((x/P0)^n)), so popt[0] == E0 directly.
popt = linear_solution.fit()
assert popt[0] == pytest.approx(10.0, rel=1e-3)
assert popt[1] == pytest.approx(5.0, rel=1e-3)
assert popt[2] == pytest.approx(1.0, rel=1e-3)
Loading
Loading