From a0d0f1b29935eb735524e501c66bf4108432ea95 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 22 Feb 2024 14:27:49 +0000 Subject: [PATCH 01/12] Create test_plots.py --- tests/unit/test_plots.py | 87 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/unit/test_plots.py diff --git a/tests/unit/test_plots.py b/tests/unit/test_plots.py new file mode 100644 index 000000000..11e935f06 --- /dev/null +++ b/tests/unit/test_plots.py @@ -0,0 +1,87 @@ +import pybop +import numpy as np +import pytest + + +class TestPlots: + """ + A class to test the plotting classes. + """ + + @pytest.fixture + def model(self): + # Define an example model + return pybop.lithium_ion.SPM() + + @pytest.mark.unit + def test_model_plots(self): + # Test plotting of Model objects + pass + + @pytest.fixture + def problem(self, model): + # Define an example problem + parameters = [ + pybop.Parameter( + "Negative particle radius [m]", + prior=pybop.Gaussian(6e-06, 0.1e-6), + bounds=[1e-6, 9e-6], + ), + pybop.Parameter( + "Positive particle radius [m]", + prior=pybop.Gaussian(4.5e-06, 0.1e-6), + bounds=[1e-6, 9e-6], + ), + ] + + # Generate data + t_eval = np.arange(0, 50, 2) + values = model.predict(t_eval=t_eval) + + # Form dataset + dataset = pybop.Dataset( + { + "Time [s]": t_eval, + "Current function [A]": values["Current [A]"].data, + "Voltage [V]": values["Voltage [V]"].data, + } + ) + + # Generate problem + return pybop.FittingProblem(model, parameters, dataset) + + @pytest.mark.unit + def test_problem_plots(self): + # Test plotting of Problem objects + pass + + @pytest.fixture + def cost(self, problem): + # Define an example cost + return pybop.SumSquaredError(problem) + + @pytest.mark.unit + def test_cost_plots(self, cost): + # Test plotting of Cost objects + pybop.quick_plot(cost.x0, cost, title="Optimised Comparison") + + # Plot the cost landscape + pybop.plot_cost2d(cost, steps=5) + + @pytest.fixture + def optim(self, cost): + # Define and run an example optimisation + optim = pybop.Optimisation(cost, optimiser=pybop.CMAES) + optim.run() + return optim + + @pytest.mark.unit + def test_optim_plots(self, optim): + # Plot convergence + pybop.plot_convergence(optim) + + # Plot the parameter traces + pybop.plot_parameters(optim) + + # Plot the cost landscape with optimisation path + pybop.plot_cost2d(optim.cost, optim=optim, steps=5) From f770cc1aca60d7da44501beac94e35638ec0945b Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 22 Feb 2024 16:49:57 +0000 Subject: [PATCH 02/12] Parametrize test_models --- tests/unit/test_models.py | 73 ++++++++++++++++++++++++--------------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index dfce88e48..b01660360 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -11,11 +11,12 @@ class TestModels: A class to test the models. """ + @pytest.mark.parametrize( + "model", + [pybop.lithium_ion.SPM(), pybop.lithium_ion.SPMe(), pybop.empirical.Thevenin()], + ) @pytest.mark.unit - def test_simulate_without_build_model(self): - # Define model - model = pybop.lithium_ion.SPM() - + def test_simulate_without_build_model(self, model): with pytest.raises( ValueError, match="Model must be built before calling simulate" ): @@ -26,37 +27,49 @@ def test_simulate_without_build_model(self): ): model.simulateS1(None, None) + @pytest.mark.parametrize( + "model", + [pybop.lithium_ion.SPM(), pybop.lithium_ion.SPMe(), pybop.empirical.Thevenin()], + ) @pytest.mark.unit - def test_predict_without_pybamm(self): - # Define model - model = pybop.lithium_ion.SPM() + def test_predict_without_pybamm(self, model): model._unprocessed_model = None with pytest.raises(ValueError): model.predict(None, None) + @pytest.mark.parametrize( + "model", + [pybop.lithium_ion.SPM(), pybop.lithium_ion.SPMe(), pybop.empirical.Thevenin()], + ) @pytest.mark.unit - def test_predict_with_inputs(self): - # Define SPM - model = pybop.lithium_ion.SPM() + def test_predict_with_inputs(self, model): + # Define inputs t_eval = np.linspace(0, 10, 100) - inputs = { - "Negative electrode active material volume fraction": 0.52, - "Positive electrode active material volume fraction": 0.63, - } - - res = model.predict(t_eval=t_eval, inputs=inputs) - assert len(res["Terminal voltage [V]"].data) == 100 + if isinstance(model, (pybop.lithium_ion.SPM, pybop.lithium_ion.SPMe)): + inputs = { + "Negative electrode active material volume fraction": 0.52, + "Positive electrode active material volume fraction": 0.63, + } + signal = "Terminal voltage [V]" + elif isinstance(model, (pybop.empirical.Thevenin)): + inputs = { + "R0 [Ohm]": 0.0002, + "R1 [Ohm]": 0.0001, + } + signal = "Voltage [V]" + else: + raise ValueError("Inputs not defined for this type of model.") - # Define SPMe - model = pybop.lithium_ion.SPMe() res = model.predict(t_eval=t_eval, inputs=inputs) - assert len(res["Terminal voltage [V]"].data) == 100 + assert len(res[signal].data) == 100 + @pytest.mark.parametrize( + "model", + [pybop.lithium_ion.SPM(), pybop.lithium_ion.SPMe()], + ) @pytest.mark.unit - def test_predict_without_allow_infeasible_solutions(self): - # Define SPM - model = pybop.lithium_ion.SPM() + def test_predict_without_allow_infeasible_solutions(self, model): model.allow_infeasible_solutions = False t_eval = np.linspace(0, 10, 100) inputs = { @@ -67,9 +80,12 @@ def test_predict_without_allow_infeasible_solutions(self): res = model.predict(t_eval=t_eval, inputs=inputs) assert np.isinf(res).any() + @pytest.mark.parametrize( + "model", + [pybop.lithium_ion.SPM(), pybop.lithium_ion.SPMe(), pybop.empirical.Thevenin()], + ) @pytest.mark.unit - def test_build(self): - model = pybop.lithium_ion.SPM() + def test_build(self, model): model.build() assert model.built_model is not None @@ -77,9 +93,12 @@ def test_build(self): model.build() assert model.built_model is not None + @pytest.mark.parametrize( + "model", + [pybop.lithium_ion.SPM(), pybop.lithium_ion.SPMe(), pybop.empirical.Thevenin()], + ) @pytest.mark.unit - def test_rebuild(self): - model = pybop.lithium_ion.SPM() + def test_rebuild(self, model): model.build() initial_built_model = model._built_model assert model._built_model is not None From 1dd263d6fb4c927a56181f6de3fe831a1335b0e6 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 22 Feb 2024 17:14:18 +0000 Subject: [PATCH 03/12] Add check on n_states --- tests/unit/test_models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index b01660360..33e49ac50 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -220,6 +220,9 @@ def test_simulate(self): solved = model.simulate(inputs, t_eval) np.testing.assert_array_almost_equal(solved, expected, decimal=5) + with pytest.raises(ValueError): + ExponentialDecay(n_states=-1) + @pytest.mark.unit def test_basemodel(self): base = pybop.BaseModel() From 8e045cfe457e7901eff8004b2d9abcb4caa96258 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 22 Feb 2024 17:42:22 +0000 Subject: [PATCH 04/12] Add test for json parameter set --- tests/unit/test_parameter_sets.py | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/unit/test_parameter_sets.py b/tests/unit/test_parameter_sets.py index 39d29d415..7e3604a86 100644 --- a/tests/unit/test_parameter_sets.py +++ b/tests/unit/test_parameter_sets.py @@ -18,3 +18,43 @@ def test_parameter_set(self): np.testing.assert_allclose( parameter_test["Negative electrode active material volume fraction"], 0.75 ) + + @pytest.mark.unit + def test_ecm_parameter_sets(self): + # Test importing a json file + json_params = pybop.ParameterSet( + json_path="examples/scripts/parameters/initial_ecm_parameters.json" + ) + json_params.import_parameters() + + params = pybop.ParameterSet( + params_dict={ + "chemistry": "ecm", + "Initial SoC": 0.5, + "Initial temperature [K]": 25 + 273.15, + "Cell capacity [A.h]": 5, + "Nominal cell capacity [A.h]": 5, + "Ambient temperature [K]": 25 + 273.15, + "Current function [A]": 5, + "Upper voltage cut-off [V]": 4.2, + "Lower voltage cut-off [V]": 3.0, + "Cell thermal mass [J/K]": 1000, + "Cell-jig heat transfer coefficient [W/K]": 10, + "Jig thermal mass [J/K]": 500, + "Jig-air heat transfer coefficient [W/K]": 10, + "Open-circuit voltage [V]": pybop.empirical.Thevenin().default_parameter_values[ + "Open-circuit voltage [V]" + ], + "R0 [Ohm]": 0.001, + "Element-1 initial overpotential [V]": 0, + "Element-2 initial overpotential [V]": 0, + "R1 [Ohm]": 0.0002, + "R2 [Ohm]": 0.0003, + "C1 [F]": 10000, + "C2 [F]": 5000, + "Entropic change [V/K]": 0.0004, + } + ) + params.import_parameters() + + assert json_params.params == params.params From 82c143d3867b6a64a30b6027d03a4260c667c660 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 22 Feb 2024 17:51:50 +0000 Subject: [PATCH 05/12] Add test for json export --- tests/unit/test_parameter_sets.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/unit/test_parameter_sets.py b/tests/unit/test_parameter_sets.py index 7e3604a86..1981e319e 100644 --- a/tests/unit/test_parameter_sets.py +++ b/tests/unit/test_parameter_sets.py @@ -58,3 +58,22 @@ def test_ecm_parameter_sets(self): params.import_parameters() assert json_params.params == params.params + + # Test exporting a json file + parameters = [ + pybop.Parameter( + "R0 [Ohm]", + prior=pybop.Gaussian(0.0002, 0.0001), + bounds=[1e-4, 1e-2], + initial_value=0.001, + ), + pybop.Parameter( + "R1 [Ohm]", + prior=pybop.Gaussian(0.0001, 0.0001), + bounds=[1e-5, 1e-2], + initial_value=0.0002, + ), + ] + params.export_parameters( + "examples/scripts/parameters/fit_ecm_parameters.json", fit_params=parameters + ) From f714b84e7c699fec0abf90a8668894a4662abb02 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 22 Feb 2024 18:07:29 +0000 Subject: [PATCH 06/12] Add test for invalid max values --- tests/unit/test_optimisation.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 6dc6c5df8..e32490e21 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -113,3 +113,9 @@ def test_halting(self, cost): optim.set_max_unchanged_iterations(1) x, __ = optim.run() assert optim._iterations == 2 + + # Test invalid maximum values + with pytest.raises(ValueError): + optim.set_max_evaluations(-1) + with pytest.raises(ValueError): + optim.set_max_unchanged_iterations(-1) From bf7b7e32dc238595b7499ccf2a3c72afaecf9009 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 22 Feb 2024 18:16:31 +0000 Subject: [PATCH 07/12] Add optimisation tests --- tests/unit/test_optimisation.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index e32490e21..6569d1ad5 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -119,3 +119,11 @@ def test_halting(self, cost): optim.set_max_evaluations(-1) with pytest.raises(ValueError): optim.set_max_unchanged_iterations(-1) + with pytest.raises(ValueError): + optim.set_max_unchanged_iterations(1, threshold=-1) + + @pytest.mark.unit + def test_unphysical_result(self, cost): + # Trigger parameters not physically viable warning + optim = pybop.Optimisation(cost=cost) + optim.check_optimal_parameters(np.array([2])) From 7db00abc008f95ff3ceb7893e256313587eb17f6 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 22 Feb 2024 18:38:21 +0000 Subject: [PATCH 08/12] Add observer tests --- tests/unit/test_observers.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/unit/test_observers.py b/tests/unit/test_observers.py index 020f8f91a..77cc09666 100644 --- a/tests/unit/test_observers.py +++ b/tests/unit/test_observers.py @@ -56,3 +56,21 @@ def test_observer(self, model, parameters, x0): np.array([[2 * y]]), decimal=4, ) + + # Test with invalid inputs + with pytest.raises(ValueError): + observer.observe(-1) + with pytest.raises(ValueError): + observer.log_likelihood( + t_eval, np.array([1]), inputs=observer._state.inputs + ) + + # Test covariance + covariance = observer.get_current_covariance() + assert np.shape(covariance) == (n, n) + + @pytest.mark.unit + def test_unbuilt_model(self, parameters): + model = ExponentialDecay() + with pytest.raises(ValueError): + pybop.Observer(parameters, model) From 2c9e64905025aef27ec566936942707fc36cb991 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 22 Feb 2024 18:47:47 +0000 Subject: [PATCH 09/12] Add tests on observer evaluate --- tests/unit/test_observers.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/unit/test_observers.py b/tests/unit/test_observers.py index 77cc09666..ab77428c5 100644 --- a/tests/unit/test_observers.py +++ b/tests/unit/test_observers.py @@ -69,6 +69,21 @@ def test_observer(self, model, parameters, x0): covariance = observer.get_current_covariance() assert np.shape(covariance) == (n, n) + # Test evaluate with different inputs + observer._time_data = t_eval + observer.evaluate(x0) + observer.evaluate(parameters) + + # Test evaluate with dataset + observer._dataset = pybop.Dataset( + { + "Time [s]": t_eval, + "Output": expected, + } + ) + observer._target = expected + observer.evaluate(x0) + @pytest.mark.unit def test_unbuilt_model(self, parameters): model = ExponentialDecay() From c3356292996b15e4a7c71e96afe3feefca947711 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 22 Feb 2024 19:02:41 +0000 Subject: [PATCH 10/12] Add tests on invalid parameter inputs --- tests/unit/test_parameter_sets.py | 7 +++++++ tests/unit/test_parameters.py | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/tests/unit/test_parameter_sets.py b/tests/unit/test_parameter_sets.py index 1981e319e..a7ac703c3 100644 --- a/tests/unit/test_parameter_sets.py +++ b/tests/unit/test_parameter_sets.py @@ -77,3 +77,10 @@ def test_ecm_parameter_sets(self): params.export_parameters( "examples/scripts/parameters/fit_ecm_parameters.json", fit_params=parameters ) + + # Test error when there no parameters to export + empty_params = pybop.ParameterSet() + with pytest.raises(ValueError): + empty_params.export_parameters( + "examples/scripts/parameters/fit_ecm_parameters.json" + ) diff --git a/tests/unit/test_parameters.py b/tests/unit/test_parameters.py index 52f0c69fd..28d5e5cd0 100644 --- a/tests/unit/test_parameters.py +++ b/tests/unit/test_parameters.py @@ -51,3 +51,17 @@ def test_parameter_margin(self, parameter): assert parameter.margin == 1e-4 parameter.set_margin(margin=1e-3) assert parameter.margin == 1e-3 + + @pytest.mark.unit + def test_invalid_inputs(self, parameter): + # Test error with invalid value + with pytest.raises(ValueError): + parameter.set_margin(margin=-1) + + # Test error with no parameter value + with pytest.raises(ValueError): + parameter.update() + + # Test error with opposite bounds + with pytest.raises(ValueError): + pybop.Parameter("Name", bounds=[0.7, 0.3]) From 17a12aa9b05032f6d51019f9773f8254e7ee3d0e Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 22 Feb 2024 19:06:52 +0000 Subject: [PATCH 11/12] Add invalid sample size tests --- tests/unit/test_priors.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/unit/test_priors.py b/tests/unit/test_priors.py index 342c35c46..d9b73388e 100644 --- a/tests/unit/test_priors.py +++ b/tests/unit/test_priors.py @@ -57,3 +57,12 @@ def test_repr(self, Gaussian, Uniform, Exponential): assert repr(Gaussian) == "Gaussian, mean: 0.5, sigma: 1" assert repr(Uniform) == "Uniform, lower: 0, upper: 1" assert repr(Exponential) == "Exponential, scale: 1" + + @pytest.mark.unit + def test_invalid_size(self, Gaussian, Uniform, Exponential): + with pytest.raises(ValueError): + Gaussian.rvs(-1) + with pytest.raises(ValueError): + Uniform.rvs(-1) + with pytest.raises(ValueError): + Exponential.rvs(-1) From 92a5cf3ff41e7a66afb282472b59a24b5ca26eb0 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 23 Feb 2024 11:25:13 +0000 Subject: [PATCH 12/12] Update test_models.py --- tests/unit/test_models.py | 54 +++++++++++++++------------------------ 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 33e49ac50..ce9be93e8 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -11,10 +11,17 @@ class TestModels: A class to test the models. """ - @pytest.mark.parametrize( - "model", - [pybop.lithium_ion.SPM(), pybop.lithium_ion.SPMe(), pybop.empirical.Thevenin()], + @pytest.fixture( + params=[ + pybop.lithium_ion.SPM(), + pybop.lithium_ion.SPMe(), + pybop.empirical.Thevenin(), + ] ) + def model(self, request): + model = request.param + return model.copy() + @pytest.mark.unit def test_simulate_without_build_model(self, model): with pytest.raises( @@ -27,10 +34,6 @@ def test_simulate_without_build_model(self, model): ): model.simulateS1(None, None) - @pytest.mark.parametrize( - "model", - [pybop.lithium_ion.SPM(), pybop.lithium_ion.SPMe(), pybop.empirical.Thevenin()], - ) @pytest.mark.unit def test_predict_without_pybamm(self, model): model._unprocessed_model = None @@ -38,10 +41,6 @@ def test_predict_without_pybamm(self, model): with pytest.raises(ValueError): model.predict(None, None) - @pytest.mark.parametrize( - "model", - [pybop.lithium_ion.SPM(), pybop.lithium_ion.SPMe(), pybop.empirical.Thevenin()], - ) @pytest.mark.unit def test_predict_with_inputs(self, model): # Define inputs @@ -51,39 +50,30 @@ def test_predict_with_inputs(self, model): "Negative electrode active material volume fraction": 0.52, "Positive electrode active material volume fraction": 0.63, } - signal = "Terminal voltage [V]" elif isinstance(model, (pybop.empirical.Thevenin)): inputs = { "R0 [Ohm]": 0.0002, "R1 [Ohm]": 0.0001, } - signal = "Voltage [V]" else: raise ValueError("Inputs not defined for this type of model.") res = model.predict(t_eval=t_eval, inputs=inputs) - assert len(res[signal].data) == 100 + assert len(res["Voltage [V]"].data) == 100 - @pytest.mark.parametrize( - "model", - [pybop.lithium_ion.SPM(), pybop.lithium_ion.SPMe()], - ) @pytest.mark.unit def test_predict_without_allow_infeasible_solutions(self, model): - model.allow_infeasible_solutions = False - t_eval = np.linspace(0, 10, 100) - inputs = { - "Negative electrode active material volume fraction": 0.9, - "Positive electrode active material volume fraction": 0.9, - } + if isinstance(model, (pybop.lithium_ion.SPM, pybop.lithium_ion.SPMe)): + model.allow_infeasible_solutions = False + t_eval = np.linspace(0, 10, 100) + inputs = { + "Negative electrode active material volume fraction": 0.9, + "Positive electrode active material volume fraction": 0.9, + } - res = model.predict(t_eval=t_eval, inputs=inputs) - assert np.isinf(res).any() + res = model.predict(t_eval=t_eval, inputs=inputs) + assert np.isinf(res).any() - @pytest.mark.parametrize( - "model", - [pybop.lithium_ion.SPM(), pybop.lithium_ion.SPMe(), pybop.empirical.Thevenin()], - ) @pytest.mark.unit def test_build(self, model): model.build() @@ -93,10 +83,6 @@ def test_build(self, model): model.build() assert model.built_model is not None - @pytest.mark.parametrize( - "model", - [pybop.lithium_ion.SPM(), pybop.lithium_ion.SPMe(), pybop.empirical.Thevenin()], - ) @pytest.mark.unit def test_rebuild(self, model): model.build()