From 99a9b69d84c402908c7c28a3fc56a310e5d2c9a2 Mon Sep 17 00:00:00 2001 From: Ferran Brosa Planella Date: Fri, 19 Jun 2026 14:47:14 +0100 Subject: [PATCH 1/3] update README with DOI --- README.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fcf12f5..2d49778 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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`. From 71a726eda38a4421d0f1b49d769a1cc5e251db1f Mon Sep 17 00:00:00 2001 From: Ferran Brosa Planella Date: Fri, 19 Jun 2026 14:48:10 +0100 Subject: [PATCH 2/3] rename config -> utils and add utils tests --- ragone/__init__.py | 2 +- ragone/{config.py => utils.py} | 0 tests/integration/test_ragone_pipeline.py | 22 +- tests/unit/test_utils.py | 320 ++++++++++++++++++++++ 4 files changed, 332 insertions(+), 12 deletions(-) rename ragone/{config.py => utils.py} (100%) create mode 100644 tests/unit/test_utils.py diff --git a/ragone/__init__.py b/ragone/__init__.py index 1b235d9..4d95223 100644 --- a/ragone/__init__.py +++ b/ragone/__init__.py @@ -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 diff --git a/ragone/config.py b/ragone/utils.py similarity index 100% rename from ragone/config.py rename to ragone/utils.py diff --git a/tests/integration/test_ragone_pipeline.py b/tests/integration/test_ragone_pipeline.py index f48efc9..3c17e08 100644 --- a/tests/integration/test_ragone_pipeline.py +++ b/tests/integration/test_ragone_pipeline.py @@ -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. @@ -408,23 +408,23 @@ 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 @@ -432,7 +432,7 @@ def test_get_options_sei_only(self): 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 @@ -440,7 +440,7 @@ def test_get_options_plating_only(self): 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 @@ -448,13 +448,13 @@ def test_get_options_lam_only(self): 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 @@ -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 diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py new file mode 100644 index 0000000..f76b5eb --- /dev/null +++ b/tests/unit/test_utils.py @@ -0,0 +1,320 @@ +"""Unit tests for ragone/utils.py. + +All three public helpers are covered: + - get_options() — pure Python, no pybamm dependency + - get_parameter_values() — pybamm.ParameterValues mocked + - get_var_pts() — pure Python, no pybamm dependency +""" + +import pytest +from unittest.mock import MagicMock, patch + +from ragone.utils import get_options, get_parameter_values, get_var_pts + + +# --------------------------------------------------------------------------- +# get_options +# --------------------------------------------------------------------------- + + +class TestGetOptions: + @pytest.mark.unit + def test_no_flags_returns_empty_dict_and_empty_tag(self): + options, tag = get_options() + assert options == {} + assert tag == "" + + @pytest.mark.unit + def test_sei_only_sets_sei_keys(self): + options, tag = get_options(SEI=True) + assert "SEI" in options + assert options["SEI"] == "reaction limited" + assert "SEI porosity change" in options + assert options["SEI porosity change"] == "true" + + @pytest.mark.unit + def test_sei_only_tag(self): + _, tag = get_options(SEI=True) + assert tag == "_SEI" + + @pytest.mark.unit + def test_plating_only_sets_plating_keys(self): + options, tag = get_options(plating=True) + assert "lithium plating" in options + assert options["lithium plating"] == "irreversible" + assert "lithium plating porosity change" in options + assert options["lithium plating porosity change"] == "true" + + @pytest.mark.unit + def test_plating_only_tag(self): + _, tag = get_options(plating=True) + assert tag == "_plating" + + @pytest.mark.unit + def test_lam_only_sets_lam_keys(self): + options, tag = get_options(lam=True) + assert "particle mechanics" in options + assert options["particle mechanics"] == "swelling only" + assert "loss of active material" in options + assert options["loss of active material"] == "stress-driven" + + @pytest.mark.unit + def test_lam_only_tag(self): + _, tag = get_options(lam=True) + assert tag == "_lam" + + @pytest.mark.unit + def test_all_flags_contains_all_keys(self): + options, _ = get_options(SEI=True, plating=True, lam=True) + assert "SEI" in options + assert "lithium plating" in options + assert "particle mechanics" in options + + @pytest.mark.unit + def test_all_flags_tag_order(self): + _, tag = get_options(SEI=True, plating=True, lam=True) + assert tag == "_SEI_plating_lam" + + @pytest.mark.unit + def test_sei_lam_tag(self): + _, tag = get_options(SEI=True, lam=True) + assert tag == "_SEI_lam" + + @pytest.mark.unit + def test_plating_lam_tag(self): + _, tag = get_options(plating=True, lam=True) + assert tag == "_plating_lam" + + @pytest.mark.unit + def test_no_flags_does_not_include_sei_keys(self): + options, _ = get_options() + assert "SEI" not in options + assert "lithium plating" not in options + assert "particle mechanics" not in options + + @pytest.mark.unit + def test_returns_new_dict_each_call(self): + options1, _ = get_options(SEI=True) + options2, _ = get_options(SEI=True) + assert options1 is not options2 + + +# --------------------------------------------------------------------------- +# get_parameter_values +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_pv_instances(): + """Patch pybamm.ParameterValues and return three ordered mock instances.""" + mock_okane = MagicMock(name="OKane2022") + mock_chen = MagicMock(name="Chen2020") + mock_oregan = MagicMock(name="ORegan2022") + with patch( + "ragone.utils.pybamm.ParameterValues", + side_effect=[mock_okane, mock_chen, mock_oregan], + ) as mock_cls: + yield mock_cls, mock_okane, mock_chen, mock_oregan + + +@pytest.fixture +def mock_pv_no_ageing(): + """Patch pybamm.ParameterValues for ageing=False calls.""" + mock_okane = MagicMock(name="OKane2022") + mock_chen = MagicMock(name="Chen2020") + mock_oregan = MagicMock(name="ORegan2022") + with patch( + "ragone.utils.pybamm.ParameterValues", + side_effect=[mock_okane, mock_chen, mock_oregan], + ) as mock_cls: + yield mock_cls, mock_okane, mock_chen, mock_oregan + + +class TestGetParameterValues: + @pytest.mark.unit + def test_creates_three_parameter_sets(self, mock_pv_instances): + mock_cls, *_ = mock_pv_instances + get_parameter_values() + assert mock_cls.call_count == 3 + + @pytest.mark.unit + def test_loads_okane_chen_oregan_by_name(self, mock_pv_instances): + mock_cls, *_ = mock_pv_instances + get_parameter_values() + mock_cls.assert_any_call("OKane2022") + mock_cls.assert_any_call("Chen2020") + mock_cls.assert_any_call("ORegan2022") + + @pytest.mark.unit + def test_returns_okane_instance(self, mock_pv_instances): + _, mock_okane, mock_chen, mock_oregan = mock_pv_instances + result = get_parameter_values() + assert result is mock_okane + + @pytest.mark.unit + def test_copies_negative_electrode_ocp_from_chen2020(self, mock_pv_instances): + _, mock_okane, mock_chen, _ = mock_pv_instances + get_parameter_values() + expected_value = mock_chen["Negative electrode OCP [V]"] + mock_okane.__setitem__.assert_any_call( + "Negative electrode OCP [V]", expected_value + ) + + @pytest.mark.unit + def test_copies_all_four_transport_params_from_oregan2022(self, mock_pv_instances): + _, mock_okane, _, mock_oregan = mock_pv_instances + get_parameter_values() + + transport_params = [ + "Cation transference number", + "Thermodynamic factor", + "Electrolyte conductivity [S.m-1]", + "Electrolyte diffusivity [m2.s-1]", + ] + set_keys = [c.args[0] for c in mock_okane.__setitem__.call_args_list] + for param in transport_params: + assert param in set_keys + + @pytest.mark.unit + def test_ageing_true_sets_sei_exchange_current_density(self, mock_pv_instances): + _, mock_okane, _, _ = mock_pv_instances + get_parameter_values(ageing=True) + mock_okane.__setitem__.assert_any_call( + "SEI reaction exchange current density [A.m-2]", 3.6e-8 + ) + + @pytest.mark.unit + def test_ageing_true_sets_sei_kinetic_rate(self, mock_pv_instances): + _, mock_okane, _, _ = mock_pv_instances + get_parameter_values(ageing=True) + mock_okane.__setitem__.assert_any_call( + "SEI kinetic rate constant [m.s-1]", 5e-8 + ) + + @pytest.mark.unit + def test_ageing_true_sets_ec_diffusivity(self, mock_pv_instances): + _, mock_okane, _, _ = mock_pv_instances + get_parameter_values(ageing=True) + mock_okane.__setitem__.assert_any_call("EC diffusivity [m2.s-1]", 1e-20) + + @pytest.mark.unit + def test_ageing_true_sets_plating_kinetic_rate(self, mock_pv_instances): + _, mock_okane, _, _ = mock_pv_instances + get_parameter_values(ageing=True) + mock_okane.__setitem__.assert_any_call( + "Lithium plating kinetic rate constant [m.s-1]", 4e-12 * 0.8 + ) + + @pytest.mark.unit + def test_ageing_true_sets_plating_exchange_current_density(self, mock_pv_instances): + _, mock_okane, _, _ = mock_pv_instances + get_parameter_values(ageing=True) + mock_okane.__setitem__.assert_any_call( + "Exchange-current density for plating [A.m-2]", 8e-4 + ) + + @pytest.mark.unit + def test_ageing_true_sets_lam_params(self, mock_pv_instances): + _, mock_okane, _, _ = mock_pv_instances + get_parameter_values(ageing=True) + set_keys = [c.args[0] for c in mock_okane.__setitem__.call_args_list] + assert "Negative electrode LAM constant proportional term [s-1]" in set_keys + assert "Positive electrode LAM constant proportional term [s-1]" in set_keys + assert "Negative electrode LAM constant exponential term" in set_keys + assert "Positive electrode LAM constant exponential term" in set_keys + + @pytest.mark.unit + def test_ageing_false_zeros_sei_kinetic_rate(self, mock_pv_no_ageing): + _, mock_okane, _, _ = mock_pv_no_ageing + get_parameter_values(ageing=False) + mock_okane.__setitem__.assert_any_call("SEI kinetic rate constant [m.s-1]", 0) + + @pytest.mark.unit + def test_ageing_false_zeros_sei_exchange_current_density(self, mock_pv_no_ageing): + _, mock_okane, _, _ = mock_pv_no_ageing + get_parameter_values(ageing=False) + mock_okane.__setitem__.assert_any_call( + "SEI reaction exchange current density [A.m-2]", 0 + ) + + @pytest.mark.unit + def test_ageing_false_zeros_sei_solvent_diffusivity(self, mock_pv_no_ageing): + _, mock_okane, _, _ = mock_pv_no_ageing + get_parameter_values(ageing=False) + mock_okane.__setitem__.assert_any_call("SEI solvent diffusivity [m2.s-1]", 0) + + @pytest.mark.unit + def test_ageing_false_zeros_plating_kinetic_rate(self, mock_pv_no_ageing): + _, mock_okane, _, _ = mock_pv_no_ageing + get_parameter_values(ageing=False) + mock_okane.__setitem__.assert_any_call( + "Lithium plating kinetic rate constant [m.s-1]", 0 + ) + + @pytest.mark.unit + def test_ageing_false_zeros_lam_params(self, mock_pv_no_ageing): + _, mock_okane, _, _ = mock_pv_no_ageing + get_parameter_values(ageing=False) + set_calls = mock_okane.__setitem__.call_args_list + lam_calls = {c.args[0]: c.args[1] for c in set_calls} + assert lam_calls["Negative electrode LAM constant proportional term [s-1]"] == 0 + assert lam_calls["Positive electrode LAM constant proportional term [s-1]"] == 0 + + @pytest.mark.unit + def test_ageing_defaults_to_true(self, mock_pv_instances): + """Calling with no argument should apply ageing parameters.""" + _, mock_okane, _, _ = mock_pv_instances + get_parameter_values() + set_keys = [c.args[0] for c in mock_okane.__setitem__.call_args_list] + # SEI solvent diffusivity is only zeroed in ageing=False path + assert "SEI solvent diffusivity [m2.s-1]" not in set_keys + + @pytest.mark.unit + def test_ageing_true_does_not_zero_plating_rate(self, mock_pv_instances): + """ageing=True path must not set any kinetic rates to 0.""" + _, mock_okane, _, _ = mock_pv_instances + get_parameter_values(ageing=True) + zero_calls = [ + c for c in mock_okane.__setitem__.call_args_list if c.args[1] == 0 + ] + assert zero_calls == [] + + +# --------------------------------------------------------------------------- +# get_var_pts +# --------------------------------------------------------------------------- + + +class TestGetVarPts: + @pytest.mark.unit + def test_returns_a_dict(self): + assert isinstance(get_var_pts(), dict) + + @pytest.mark.unit + def test_contains_all_spatial_keys(self): + var_pts = get_var_pts() + for key in ("x_n", "x_s", "x_p", "r_n", "r_p"): + assert key in var_pts + + @pytest.mark.unit + def test_all_values_are_positive_integers(self): + for val in get_var_pts().values(): + assert isinstance(val, int) + assert val > 0 + + @pytest.mark.unit + def test_electrode_x_values(self): + var_pts = get_var_pts() + assert var_pts["x_n"] == 100 + assert var_pts["x_s"] == 30 + assert var_pts["x_p"] == 50 + + @pytest.mark.unit + def test_particle_r_values(self): + var_pts = get_var_pts() + assert var_pts["r_n"] == 30 + assert var_pts["r_p"] == 30 + + @pytest.mark.unit + def test_returns_new_dict_each_call(self): + assert get_var_pts() is not get_var_pts() From bb66fc0a864287e7b0b2cbb99be0e5d747f72657 Mon Sep 17 00:00:00 2001 From: Ferran Brosa Planella Date: Fri, 19 Jun 2026 14:55:12 +0100 Subject: [PATCH 3/3] add more unit tests --- tests/unit/test_plotting.py | 25 +++++++++++ tests/unit/test_solution.py | 87 +++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/tests/unit/test_plotting.py b/tests/unit/test_plotting.py index 4d64e5a..c395955 100644 --- a/tests/unit/test_plotting.py +++ b/tests/unit/test_plotting.py @@ -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 diff --git a/tests/unit/test_solution.py b/tests/unit/test_solution.py index 8cfee87..d508ac9 100644 --- a/tests/unit/test_solution.py +++ b/tests/unit/test_solution.py @@ -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)