From 54e97f5d2de937ac7441c845957d2b672b6673f6 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 23 Feb 2024 11:38:01 +0000 Subject: [PATCH] 139b Add unit tests for plotting and Thevenin model (#212) * Create test_plots.py * Parametrize test_models * Add check on n_states * Add test for json parameter set * Add test for json export * Add test for invalid max values * Add optimisation tests * Add observer tests * Add tests on observer evaluate * Add tests on invalid parameter inputs * Add invalid sample size tests --- tests/unit/test_models.py | 80 +++++++++++++++------------- tests/unit/test_observers.py | 33 ++++++++++++ tests/unit/test_optimisation.py | 14 +++++ tests/unit/test_parameter_sets.py | 66 +++++++++++++++++++++++ tests/unit/test_parameters.py | 14 +++++ tests/unit/test_plots.py | 87 +++++++++++++++++++++++++++++++ tests/unit/test_priors.py | 9 ++++ 7 files changed, 267 insertions(+), 36 deletions(-) create mode 100644 tests/unit/test_plots.py diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index dfce88e48..ce9be93e8 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -11,11 +11,19 @@ class TestModels: A class to test the models. """ - @pytest.mark.unit - def test_simulate_without_build_model(self): - # Define model - model = pybop.lithium_ion.SPM() + @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( ValueError, match="Model must be built before calling simulate" ): @@ -27,49 +35,47 @@ def test_simulate_without_build_model(self): model.simulateS1(None, None) @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.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, + } + elif isinstance(model, (pybop.empirical.Thevenin)): + inputs = { + "R0 [Ohm]": 0.0002, + "R1 [Ohm]": 0.0001, + } + 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["Voltage [V]"].data) == 100 @pytest.mark.unit - def test_predict_without_allow_infeasible_solutions(self): - # Define SPM - model = pybop.lithium_ion.SPM() - 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() + def test_predict_without_allow_infeasible_solutions(self, model): + 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() @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 @@ -78,8 +84,7 @@ def test_build(self): assert model.built_model is not None @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 @@ -201,6 +206,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() diff --git a/tests/unit/test_observers.py b/tests/unit/test_observers.py index 020f8f91a..ab77428c5 100644 --- a/tests/unit/test_observers.py +++ b/tests/unit/test_observers.py @@ -56,3 +56,36 @@ 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) + + # 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() + with pytest.raises(ValueError): + pybop.Observer(parameters, model) diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 6dc6c5df8..6569d1ad5 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -113,3 +113,17 @@ 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) + 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])) diff --git a/tests/unit/test_parameter_sets.py b/tests/unit/test_parameter_sets.py index 39d29d415..a7ac703c3 100644 --- a/tests/unit/test_parameter_sets.py +++ b/tests/unit/test_parameter_sets.py @@ -18,3 +18,69 @@ 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 + + # 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 + ) + + # 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]) 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) 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)