From 1f1e91de5a253b35b4cf69a75b052c39f29e9ec1 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Sun, 5 May 2024 04:30:04 +0100 Subject: [PATCH 01/13] fixes to pybop.experiment, pybop.base_model for pybamm@develop ahead of pybamm v24.5 --- examples/scripts/parameters/example_BPX.json | 7 ++----- pybop/_experiment.py | 2 -- pybop/models/base_model.py | 16 ++++++++++++---- pyproject.toml | 4 +++- tests/unit/test_experiment.py | 6 +++--- 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/examples/scripts/parameters/example_BPX.json b/examples/scripts/parameters/example_BPX.json index 43bbcf90..1e1efaad 100644 --- a/examples/scripts/parameters/example_BPX.json +++ b/examples/scripts/parameters/example_BPX.json @@ -1,7 +1,7 @@ { "Header": { - "BPX": 0.1, - "Title": "Parameterisation example of an LFP|graphite 2 Ah cylindrical 18650 cell.", + "BPX": 0.4, + "Title": "Parameterisation example of an LFP|graphite 2 Ah cylindrical 18650 cell. File downloaded on 19/3/24 from https://github.com/FaradayInstitution/BPX/blob/main/examples/lfp_18650_cell_BPX.json", "Description": "LFP|graphite 2 Ah cylindrical 18650 cell. Parameterisation by About:Energy Limited (aboutenergy.io), December 2022, based on cell cycling data, and electrode data gathered after cell teardown. Electrolyte properties from Nyman et al. 2008 (doi:10.1016/j.electacta.2008.04.023). Negative electrode entropic coefficient data are from O'Regan et al. 2022 (doi:10.1016/j.electacta.2022.140700). Positive electrode entropic coefficient data are from Gerver and Meyers 2011 (doi:10.1149/1.3591799). Other thermal properties are estimated.", "Model": "DFN" }, @@ -70,9 +70,6 @@ "Thickness [m]": 2e-05, "Porosity": 0.47, "Transport efficiency": 0.3222 - }, - "User-defined": { - "Source:": "An example BPX json file downloaded on 19/3/24 from https://github.com/FaradayInstitution/BPX/blob/main/examples/lfp_18650_cell_BPX.json" } } } diff --git a/pybop/_experiment.py b/pybop/_experiment.py index 1c495384..a651dffc 100644 --- a/pybop/_experiment.py +++ b/pybop/_experiment.py @@ -49,6 +49,4 @@ def __init__( period, temperature, termination, - drive_cycles, - cccv_handling, ) diff --git a/pybop/models/base_model.py b/pybop/models/base_model.py index a26a5f31..4134bfd8 100644 --- a/pybop/models/base_model.py +++ b/pybop/models/base_model.py @@ -121,9 +121,13 @@ def build( self.set_params() self._mesh = pybamm.Mesh(self.geometry, self.submesh_types, self.var_pts) - self._disc = pybamm.Discretisation(self.mesh, self.spatial_methods) + self._disc = pybamm.Discretisation( + mesh=self.mesh, + spatial_methods=self.spatial_methods, + check_model=check_model, + ) self._built_model = self._disc.process_model( - self._model_with_set_params, inplace=False, check_model=check_model + self._model_with_set_params, inplace=False ) # Clear solver and setup model @@ -229,9 +233,13 @@ def rebuild( self.set_params(rebuild=True) self._mesh = pybamm.Mesh(self.geometry, self.submesh_types, self.var_pts) - self._disc = pybamm.Discretisation(self.mesh, self.spatial_methods) + self._disc = pybamm.Discretisation( + mesh=self.mesh, + spatial_methods=self.spatial_methods, + check_model=check_model, + ) self._built_model = self._disc.process_model( - self._model_with_set_params, inplace=False, check_model=check_model + self._model_with_set_params, inplace=False ) # Clear solver and setup model diff --git a/pyproject.toml b/pyproject.toml index 14ec41be..7e4f89ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,9 @@ classifiers = [ ] requires-python = ">=3.9, <3.13" dependencies = [ - "pybamm>=23.9", + # "pybamm>=23.9", + # "pybamm @ git+https://github.com/pybamm-team/PyBaMM@develop", + "pybamm @ git+https://github.com/BradyPlanden/PyBaMM@fix-electrode-diffusion-rename", "numpy>=1.16", "scipy>=1.3", "pints>=0.5", diff --git a/tests/unit/test_experiment.py b/tests/unit/test_experiment.py index 6d18ef50..71355ff8 100644 --- a/tests/unit/test_experiment.py +++ b/tests/unit/test_experiment.py @@ -18,9 +18,9 @@ def test_experiment(self): pybop_experiment = pybop.Experiment(protocol) pybamm_experiment = pybamm.Experiment(protocol) - assert [ - step.to_dict() for step in pybop_experiment.operating_conditions_steps - ] == [step.to_dict() for step in pybamm_experiment.operating_conditions_steps] + assert [step.to_dict() for step in pybop_experiment.steps] == [ + step.to_dict() for step in pybamm_experiment.steps + ] assert pybop_experiment.cycle_lengths == pybamm_experiment.cycle_lengths From 1770dc818ebd714b088a4715eaebf72c03dda7a6 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 6 May 2024 00:15:05 +0100 Subject: [PATCH 02/13] increment number of states in spm_UKF example --- examples/scripts/spm_UKF.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/scripts/spm_UKF.py b/examples/scripts/spm_UKF.py index 5299c581..49380aba 100644 --- a/examples/scripts/spm_UKF.py +++ b/examples/scripts/spm_UKF.py @@ -22,7 +22,7 @@ # Make a prediction with measurement noise sigma = 0.001 -t_eval = np.arange(0, 300, 2) +t_eval = np.arange(0, 900, 0.5) values = model.predict(t_eval=t_eval) corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) @@ -42,8 +42,8 @@ signal = ["Voltage [V]"] n_states = model.n_states n_signals = len(signal) -covariance = np.diag([0] * 19 + [sigma**2] + [0] * 19 + [sigma**2]) -process_noise = np.diag([0] * 19 + [1e-6] + [0] * 19 + [1e-6]) +covariance = np.diag([0] * 20 + [sigma**2] + [0] * 20 + [sigma**2]) +process_noise = np.diag([0] * 20 + [1e-6] + [0] * 20 + [1e-6]) measurement_noise = np.diag([sigma**2]) observer = pybop.UnscentedKalmanFilterObserver( parameters, From 786d9ca637740a4c49eb84ffabca0ea0c6d6c948 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 22 May 2024 16:55:41 +0100 Subject: [PATCH 03/13] fix: update non-converged DFN test --- pyproject.toml | 3 +-- tests/unit/test_models.py | 9 ++++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9d2b02f9..0de3b16e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,8 +27,7 @@ classifiers = [ requires-python = ">=3.9, <3.13" dependencies = [ # "pybamm>=23.9", - # "pybamm @ git+https://github.com/pybamm-team/PyBaMM@develop", - "pybamm @ git+https://github.com/BradyPlanden/PyBaMM@fix-electrode-diffusion-rename", + "pybamm @ git+https://github.com/pybamm-team/PyBaMM@develop", "numpy>=1.16", "scipy>=1.3", "pints>=0.5", diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 3137f6f2..671097b7 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -282,10 +282,13 @@ def test_non_converged_solution(self): "Voltage [V]": np.zeros(100), } ) - problem = pybop.FittingProblem(model, parameters=parameters, dataset=dataset) - res = problem.evaluate([-0.2, -0.2]) - _, res_grad = problem.evaluateS1([-0.2, -0.2]) + + # Simulate the DFN with active material values of 0 + # This should not converge, and as such, the + # solution from model.simulate should be inf + res = problem.evaluate([0, 0]) + _, res_grad = problem.evaluateS1([0, 0]) for key in problem.signal: assert np.isinf(res.get(key, [])).any() From 7711a4e80658c26feec3cd6a4e45f35981382502 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 27 Jun 2024 18:47:28 +0100 Subject: [PATCH 04/13] update target pybamm source to forked develop w/ diffusivity fix --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0de3b16e..1b024709 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ requires-python = ">=3.9, <3.13" dependencies = [ # "pybamm>=23.9", - "pybamm @ git+https://github.com/pybamm-team/PyBaMM@develop", + "pybamm @ git+https://github.com/bradyplanden/PyBaMM@v24.5rc0-diffusivity-test", "numpy>=1.16", "scipy>=1.3", "pints>=0.5", From a76959f41de09d8a6f39449fa7468d2a00b9afcb Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 28 Jun 2024 09:58:45 +0100 Subject: [PATCH 05/13] tests: updts to test_non_converged_solution. Changes to pybamm v24.5 have resulted in solver.solve() to return values for incorrect inputs values when not requesting sensitivities. This removes the test that assert output == np.inf in this case. --- tests/unit/test_models.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 2bb57733..a458a4d2 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -342,12 +342,11 @@ def test_non_converged_solution(self): ) problem = pybop.FittingProblem(model, parameters=parameters, dataset=dataset) - # Simulate the DFN with active material values of 0 - # This should not converge, and as such, the - # solution from model.simulate should be inf - res = problem.evaluate([0, 0]) - _, res_grad = problem.evaluateS1([0, 0]) + # Simulate the DFN with active material values of 0. + # The solution from model.simulateS1 should be inf + # and the gradient should be inf. + output_S1, res_grad = problem.evaluateS1([0, 0]) for key in problem.signal: - assert np.isinf(res.get(key, [])).any() + assert np.isinf(output_S1.get(key, [])).any() assert np.isinf(res_grad).any() From c8deb1501eb9b3dfae3bdf9fee0c304316d22fa8 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 28 Jun 2024 10:50:06 +0100 Subject: [PATCH 06/13] test: updt asserts to capture differing outputs --- tests/unit/test_models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index a458a4d2..4a05b55a 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -343,10 +343,11 @@ def test_non_converged_solution(self): problem = pybop.FittingProblem(model, parameters=parameters, dataset=dataset) # Simulate the DFN with active material values of 0. - # The solution from model.simulateS1 should be inf - # and the gradient should be inf. + # The solutions will not change as the solver will not converge. + output = problem.evaluate([0, 0]) output_S1, res_grad = problem.evaluateS1([0, 0]) for key in problem.signal: + assert np.allclose(output.get(key, [])[0], output.get(key, [])) assert np.isinf(output_S1.get(key, [])).any() assert np.isinf(res_grad).any() From 06dd97980ee411b2fac8b51d15185121f2e42598 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 28 Jun 2024 11:08:25 +0100 Subject: [PATCH 07/13] test: revert to allclose as output vector varies for each OS --- tests/unit/test_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 4a05b55a..6fc431d5 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -349,5 +349,5 @@ def test_non_converged_solution(self): for key in problem.signal: assert np.allclose(output.get(key, [])[0], output.get(key, [])) - assert np.isinf(output_S1.get(key, [])).any() + assert np.allclose(output_S1.get(key, [])[0], output_S1.get(key, [])) assert np.isinf(res_grad).any() From c2613700ca18c51d95b57cd0c5337fe655c3e0c4 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 28 Jun 2024 18:26:54 +0100 Subject: [PATCH 08/13] tests: remove gradient == np.inf assert --- tests/unit/test_models.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 6fc431d5..07147a74 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -345,9 +345,8 @@ def test_non_converged_solution(self): # Simulate the DFN with active material values of 0. # The solutions will not change as the solver will not converge. output = problem.evaluate([0, 0]) - output_S1, res_grad = problem.evaluateS1([0, 0]) + output_S1, _ = problem.evaluateS1([0, 0]) for key in problem.signal: assert np.allclose(output.get(key, [])[0], output.get(key, [])) assert np.allclose(output_S1.get(key, [])[0], output_S1.get(key, [])) - assert np.isinf(res_grad).any() From 3e4012850c4de4d340fd326bd8797bf95402e787 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Sun, 30 Jun 2024 13:34:09 +0100 Subject: [PATCH 09/13] tests: Add missing asserts --- tests/unit/test_cost.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_cost.py b/tests/unit/test_cost.py index 3c0d8151..49a1b8b0 100644 --- a/tests/unit/test_cost.py +++ b/tests/unit/test_cost.py @@ -149,7 +149,8 @@ def test_costs(self, cost): cost([1.1]) # Test option setting - cost.set_fail_gradient(1) + cost.set_fail_gradient(10) + assert cost._de == 10 if isinstance(cost, pybop.SumSquaredError): e, de = cost.evaluateS1([0.5]) @@ -229,4 +230,4 @@ def test_design_costs( # Compute after updating nominal capacity cost = cost_class(problem, update_capacity=True) - cost([0.4]) + assert np.isfinite(cost([0.4])) From 40133fb4ebd918259dcffc0d33ecb5e5ee58b180 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Sat, 20 Jul 2024 11:24:58 +0100 Subject: [PATCH 10/13] build: increment pybamm to 24.5rc2, ci: increment tests alongside. --- .gitignore | 3 +++ CHANGELOG.md | 1 + examples/scripts/gitt.py | 4 ++-- pybop/models/lithium_ion/weppner_huggins.py | 2 +- pyproject.toml | 3 +-- scripts/ci/build_matrix.sh | 11 +++++++---- tests/integration/test_model_experiment_changes.py | 2 +- tests/unit/test_models.py | 2 +- 8 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 3c3bb708..2bafcca6 100644 --- a/.gitignore +++ b/.gitignore @@ -314,3 +314,6 @@ $RECYCLE.BIN/ # Airspeed Velocity *.asv/ results/ + +# Pycharm +*.idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md index be08914b..9450ad11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - [#393](https://github.com/pybop-team/PyBOP/pull/383) - Adds Minkowski and SumofPower cost classes, with an example and corresponding tests. - [#403](https://github.com/pybop-team/PyBOP/pull/403/) - Adds lychee link checking action. +- [#313](https://github.com/pybop-team/PyBOP/pull/313/) - Fixes for PyBaMM v24.5, drops support for PyBaMM v23.9, v24.1 ## Bug Fixes diff --git a/examples/scripts/gitt.py b/examples/scripts/gitt.py index 6d3b4a94..4b8c1561 100644 --- a/examples/scripts/gitt.py +++ b/examples/scripts/gitt.py @@ -36,10 +36,10 @@ model = pybop.lithium_ion.WeppnerHuggins(parameter_set=parameter_set) parameters = pybop.Parameter( - "Positive electrode diffusivity [m2.s-1]", + "Positive particle diffusivity [m2.s-1]", prior=pybop.Gaussian(5e-14, 1e-13), bounds=[1e-16, 1e-11], - true_value=parameter_set["Positive electrode diffusivity [m2.s-1]"], + true_value=parameter_set["Positive particle diffusivity [m2.s-1]"], ) problem = pybop.FittingProblem( diff --git a/pybop/models/lithium_ion/weppner_huggins.py b/pybop/models/lithium_ion/weppner_huggins.py index 74c42c70..b8707cca 100644 --- a/pybop/models/lithium_ion/weppner_huggins.py +++ b/pybop/models/lithium_ion/weppner_huggins.py @@ -65,7 +65,7 @@ def __init__(self, name="Weppner & Huggins model", **model_kwargs): # Parameters ###################### - d_s = Parameter("Positive electrode diffusivity [m2.s-1]") + d_s = Parameter("Positive particle diffusivity [m2.s-1]") c_s_max = Parameter("Maximum concentration in positive electrode [mol.m-3]") diff --git a/pyproject.toml b/pyproject.toml index cd99a096..eacb65f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,8 +26,7 @@ classifiers = [ ] requires-python = ">=3.9, <3.13" dependencies = [ - # "pybamm>=23.9", - "pybamm @ git+https://github.com/bradyplanden/PyBaMM@v24.5rc0-diffusivity-test", + "pybamm>=24.5rc2", "numpy>=1.16, <2.0", "scipy>=1.3", "pints>=0.5", diff --git a/scripts/ci/build_matrix.sh b/scripts/ci/build_matrix.sh index 9dfe3224..88d5b9b3 100755 --- a/scripts/ci/build_matrix.sh +++ b/scripts/ci/build_matrix.sh @@ -11,8 +11,11 @@ python_version=("3.9" "3.10" "3.11" "3.12") os=("ubuntu-latest" "windows-latest" "macos-13" "macos-14") -# This command fetches the last two PyBaMM versions excluding release candidates from PyPI -pybamm_version=($(curl -s https://pypi.org/pypi/pybamm/json | jq -r '.releases | keys[]' | grep -v rc | tail -n 2 | paste -sd " " -)) +# This command fetches the last PyBaMM version excluding release candidates from PyPI +#pybamm_version=($(curl -s https://pypi.org/pypi/pybamm/json | jq -r '.releases | keys[]' | grep -v rc | tail -n 1 | paste -sd " " -)) + +# This command fetches the last PyBaMM versions including release candidates from PyPI +pybamm_version=($(curl -s https://pypi.org/pypi/pybamm/json | jq -r '.releases | keys[]' | tail -n 1 | paste -sd " " -)) # open dict json='{ @@ -40,7 +43,7 @@ json+=' ] }' -# Filter out incompatible combinations -json=$(echo "$json" | jq -c 'del(.include[] | select(.pybamm_version == "23.9" and .python_version == "3.12"))') +# Example for filtering out incompatible combinations +#json=$(echo "$json" | jq -c 'del(.include[] | select(.pybamm_version == "23.9" and .python_version == "3.12"))') echo "$json" | jq -c . diff --git a/tests/integration/test_model_experiment_changes.py b/tests/integration/test_model_experiment_changes.py index 64d27132..7bcc33dd 100644 --- a/tests/integration/test_model_experiment_changes.py +++ b/tests/integration/test_model_experiment_changes.py @@ -22,7 +22,7 @@ class TestModelAndExperimentChanges: ), pybop.Parameters( pybop.Parameter( # non-geometric parameter - "Positive electrode diffusivity [m2.s-1]", + "Positive particle diffusivity [m2.s-1]", prior=pybop.Gaussian(3.43e-15, 1e-15), bounds=[1e-15, 5e-15], true_value=4e-15, diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index b2e29842..b12b3639 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -361,7 +361,7 @@ def test_non_converged_solution(self): problem = pybop.FittingProblem(model, parameters=parameters, dataset=dataset) # Simulate the DFN with active material values of 0. - # The solutions will not change as the solver will not converge. + # The solution elements will not change as the solver will not converge. output = problem.evaluate([0, 0]) output_S1, _ = problem.evaluateS1([0, 0]) From 4c100ff618b9ee4327040e77b1fd7656e8ee9ce3 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Sat, 20 Jul 2024 15:13:38 +0100 Subject: [PATCH 11/13] tests: increase coverage, fix flaky observer tests --- tests/unit/test_observer_unscented_kalman.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/unit/test_observer_unscented_kalman.py b/tests/unit/test_observer_unscented_kalman.py index 0b5d3067..c83fae31 100644 --- a/tests/unit/test_observer_unscented_kalman.py +++ b/tests/unit/test_observer_unscented_kalman.py @@ -93,6 +93,15 @@ def test_cholupdate(self): SquareRootUKF.cholupdate(R1_, u.copy(), 1.0) np.testing.assert_array_almost_equal(R1, R1_) + # Test hypot + f = 10.0 + j = 20.0 + out_1 = f * np.sqrt(1 + 1.0 * f**2 / j**2) + np.testing.assert_allclose(SquareRootUKF.hypot(f, j, 1.0), out_1) + + j = 10.0 + np.testing.assert_allclose(SquareRootUKF.hypot(f, j, 1.0), float(0)) + @pytest.mark.unit def test_unscented_kalman_filter(self, dataset, observer): t_eval = dataset["Time [s]"] @@ -116,6 +125,11 @@ def test_unscented_kalman_filter(self, dataset, observer): decimal=4, ) + # Test get covariance + cov = observer.get_current_measure() + assert cov.shape == (1, 1) + assert float(0) <= cov[0] + @pytest.mark.unit def test_observe_no_measurement(self, observer): with pytest.raises(ValueError): From f3a32bcbd5fda8e2b6fb79c69989f7f41a31de5c Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Mon, 22 Jul 2024 10:54:43 +0100 Subject: [PATCH 12/13] Add a WeightedCost (#329) * Add a WeightedCost * Fix setting * Add tests * Update base_cost.py * Update CHANGELOG.md * Update imports * Update x0 to [0.5] * Update spm_weighted_cost.py * Update TypeError with test * Update spm_weighted_cost.py * Update evaluate and _evaluate * Pass current_sensitivities to MAP * Add test_weighted_design_cost * Add evaluate back into GaussianLogLikelihood * Update to super() * Update prediction to self._current_prediction * Update y to self._current_prediction * Update cost_list to args * Add descriptions * refactor: move WeightedCost into separate file --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Brady Planden Co-authored-by: Brady Planden <55357039+BradyPlanden@users.noreply.github.com> --- CHANGELOG.md | 1 + examples/scripts/spm_weighted_cost.py | 61 ++++++++++++ pybop/__init__.py | 1 + pybop/costs/_likelihoods.py | 64 ++++++++---- pybop/costs/_weighted_cost.py | 138 ++++++++++++++++++++++++++ pybop/costs/base_cost.py | 20 +++- pybop/costs/design_costs.py | 2 + pybop/costs/fitting_costs.py | 92 +++++++++++------ tests/unit/test_cost.py | 101 ++++++++++++++++--- 9 files changed, 413 insertions(+), 67 deletions(-) create mode 100644 examples/scripts/spm_weighted_cost.py create mode 100644 pybop/costs/_weighted_cost.py diff --git a/CHANGELOG.md b/CHANGELOG.md index be08914b..5a92d6d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Features +- [#327](https://github.com/pybop-team/PyBOP/issues/327) - Adds the `WeightedCost` subclass, defines when to evaluate a problem and adds the `spm_weighted_cost` example script. - [#393](https://github.com/pybop-team/PyBOP/pull/383) - Adds Minkowski and SumofPower cost classes, with an example and corresponding tests. - [#403](https://github.com/pybop-team/PyBOP/pull/403/) - Adds lychee link checking action. diff --git a/examples/scripts/spm_weighted_cost.py b/examples/scripts/spm_weighted_cost.py new file mode 100644 index 00000000..74c43a33 --- /dev/null +++ b/examples/scripts/spm_weighted_cost.py @@ -0,0 +1,61 @@ +import numpy as np + +import pybop + +# Parameter set and model definition +parameter_set = pybop.ParameterSet.pybamm("Chen2020") +model = pybop.lithium_ion.SPM(parameter_set=parameter_set) + +# Fitting parameters +parameters = pybop.Parameters( + pybop.Parameter( + "Negative electrode active material volume fraction", + prior=pybop.Gaussian(0.68, 0.05), + bounds=[0.5, 0.8], + true_value=parameter_set["Negative electrode active material volume fraction"], + ), + pybop.Parameter( + "Positive electrode active material volume fraction", + prior=pybop.Gaussian(0.58, 0.05), + bounds=[0.4, 0.7], + true_value=parameter_set["Positive electrode active material volume fraction"], + ), +) + +# Generate data +sigma = 0.001 +t_eval = np.arange(0, 900, 3) +values = model.predict(t_eval=t_eval) +corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) + +# Form dataset +dataset = pybop.Dataset( + { + "Time [s]": t_eval, + "Current function [A]": values["Current [A]"].data, + "Voltage [V]": corrupt_values, + } +) + +# Generate problem, cost function, and optimisation class +problem = pybop.FittingProblem(model, parameters, dataset) +cost1 = pybop.SumSquaredError(problem) +cost2 = pybop.RootMeanSquaredError(problem) +weighted_cost = pybop.WeightedCost(cost1, cost2, weights=[1, 100]) + +for cost in [weighted_cost, cost1, cost2]: + optim = pybop.IRPropMin(cost, max_iterations=60) + + # Run the optimisation + x, final_cost = optim.run() + print("True parameters:", parameters.true_value()) + print("Estimated parameters:", x) + + # Plot the timeseries output + pybop.quick_plot(problem, problem_inputs=x, title="Optimised Comparison") + + # Plot convergence + pybop.plot_convergence(optim) + + # Plot the cost landscape with optimisation path + pybop.plot2d(optim, steps=15) diff --git a/pybop/__init__.py b/pybop/__init__.py index c9aabb8d..922a6480 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -100,6 +100,7 @@ GaussianLogLikelihoodKnownSigma, MAP, ) +from .costs._weighted_cost import WeightedCost # # Optimiser class diff --git a/pybop/costs/_likelihoods.py b/pybop/costs/_likelihoods.py index e65f02fc..1f96e2fb 100644 --- a/pybop/costs/_likelihoods.py +++ b/pybop/costs/_likelihoods.py @@ -43,16 +43,17 @@ def _evaluate(self, inputs: Inputs, grad: Union[None, np.ndarray] = None) -> flo """ Evaluates the Gaussian log-likelihood for the given parameters with known sigma. """ - y = self.problem.evaluate(inputs) - - if not self.verify_prediction(y): + if not self.verify_prediction(self._current_prediction): return -np.inf e = np.asarray( [ np.sum( self._offset - + self._multip * np.sum((self._target[signal] - y[signal]) ** 2.0) + + self._multip + * np.sum( + (self._target[signal] - self._current_prediction[signal]) ** 2.0 + ) ) for signal in self.signal ] @@ -62,17 +63,22 @@ def _evaluate(self, inputs: Inputs, grad: Union[None, np.ndarray] = None) -> flo def _evaluateS1(self, inputs: Inputs) -> tuple[float, np.ndarray]: """ - Calls the problem.evaluateS1 method and calculates the log-likelihood and gradient. + Calculates the log-likelihood and gradient. """ - y, dy = self.problem.evaluateS1(inputs) - - if not self.verify_prediction(y): + if not self.verify_prediction(self._current_prediction): return -np.inf, -self._de * np.ones(self.n_parameters) likelihood = self._evaluate(inputs) - r = np.asarray([self._target[signal] - y[signal] for signal in self.signal]) - dl = np.sum((np.sum((r * dy.T), axis=2) / self.sigma2), axis=1) + r = np.asarray( + [ + self._target[signal] - self._current_prediction[signal] + for signal in self.signal + ] + ) + dl = np.sum( + (np.sum((r * self._current_sensitivities.T), axis=2) / self.sigma2), axis=1 + ) return likelihood, dl @@ -117,6 +123,7 @@ def __init__( super().__init__(problem) self._dsigma_scale = dsigma_scale self._logpi = -0.5 * self.n_time_data * np.log(2 * np.pi) + self._fixed_problem = False # keep problem evaluation within _evaluate self.sigma = Parameters() self._add_sigma_parameters(sigma0) @@ -189,8 +196,10 @@ def _evaluate(self, inputs: Inputs, grad: Union[None, np.ndarray] = None) -> flo if np.any(sigma <= 0): return -np.inf - y = self.problem.evaluate(self.problem.parameters.as_dict()) - if not self.verify_prediction(y): + self._current_prediction = self.problem.evaluate( + self.problem.parameters.as_dict() + ) + if not self.verify_prediction(self._current_prediction): return -np.inf e = np.asarray( @@ -198,7 +207,9 @@ def _evaluate(self, inputs: Inputs, grad: Union[None, np.ndarray] = None) -> flo np.sum( self._logpi - self.n_time_data * np.log(sigma) - - np.sum((self._target[signal] - y[signal]) ** 2.0) + - np.sum( + (self._target[signal] - self._current_prediction[signal]) ** 2.0 + ) / (2.0 * sigma**2.0) ) for signal in self.signal @@ -209,7 +220,7 @@ def _evaluate(self, inputs: Inputs, grad: Union[None, np.ndarray] = None) -> flo def _evaluateS1(self, inputs: Inputs) -> tuple[float, np.ndarray]: """ - Calls the problem.evaluateS1 method and calculates the log-likelihood. + Calculates the log-likelihood and sensitivities. Parameters ---------- @@ -227,14 +238,23 @@ def _evaluateS1(self, inputs: Inputs) -> tuple[float, np.ndarray]: if np.any(sigma <= 0): return -np.inf, -self._de * np.ones(self.n_parameters) - y, dy = self.problem.evaluateS1(self.problem.parameters.as_dict()) - if not self.verify_prediction(y): + self._current_prediction, self._current_sensitivities = self.problem.evaluateS1( + self.problem.parameters.as_dict() + ) + if not self.verify_prediction(self._current_prediction): return -np.inf, -self._de * np.ones(self.n_parameters) likelihood = self._evaluate(inputs) - r = np.asarray([self._target[signal] - y[signal] for signal in self.signal]) - dl = np.sum((np.sum((r * dy.T), axis=2) / (sigma**2.0)), axis=1) + r = np.asarray( + [ + self._target[signal] - self._current_prediction[signal] + for signal in self.signal + ] + ) + dl = np.sum( + (np.sum((r * self._current_sensitivities.T), axis=2) / (sigma**2.0)), axis=1 + ) dsigma = ( -self.n_time_data / sigma + np.sum(r**2.0, axis=1) / (sigma**3.0) ) / self._dsigma_scale @@ -300,7 +320,10 @@ def _evaluate(self, inputs: Inputs, grad=None) -> float: if not np.isfinite(log_prior).any(): return -np.inf + if self._fixed_problem: + self.likelihood._current_prediction = self._current_prediction log_likelihood = self.likelihood._evaluate(inputs) + posterior = log_likelihood + log_prior return posterior @@ -331,6 +354,11 @@ def _evaluateS1(self, inputs: Inputs) -> tuple[float, np.ndarray]: if not np.isfinite(log_prior).any(): return -np.inf, -self._de * np.ones(self.n_parameters) + if self._fixed_problem: + ( + self.likelihood._current_prediction, + self.likelihood._current_sensitivities, + ) = self._current_prediction, self._current_sensitivities log_likelihood, dl = self.likelihood._evaluateS1(inputs) # Compute a finite difference approximation of the gradient of the log prior diff --git a/pybop/costs/_weighted_cost.py b/pybop/costs/_weighted_cost.py new file mode 100644 index 00000000..effa5a51 --- /dev/null +++ b/pybop/costs/_weighted_cost.py @@ -0,0 +1,138 @@ +from typing import Optional + +import numpy as np + +from pybop import BaseCost +from pybop.parameters.parameter import Inputs + + +class WeightedCost(BaseCost): + """ + A subclass for constructing a linear combination of cost functions as + a single weighted cost function. + + Inherits all parameters and attributes from ``BaseCost``. + + Additional Attributes + --------------------- + costs : list[pybop.BaseCost] + A list of PyBOP cost objects. + weights : list[float] + A list of values with which to weight the cost values. + _different_problems : bool + If True, the problem for each cost is evaluated independently during + each evaluation of the cost (default: False). + """ + + def __init__(self, *args, weights: Optional[list[float]] = None): + self.costs = [] + for cost in args: + if not isinstance(cost, BaseCost): + raise TypeError(f"Received {type(cost)} instead of cost object.") + self.costs.append(cost) + self.weights = weights + self._different_problems = False + + if self.weights is None: + self.weights = np.ones(len(self.costs)) + elif isinstance(self.weights, list): + self.weights = np.array(self.weights) + if not isinstance(self.weights, np.ndarray): + raise TypeError( + "Expected a list or array of weights the same length as costs." + ) + if not len(self.weights) == len(self.costs): + raise ValueError( + "Expected a list or array of weights the same length as costs." + ) + + # Check if all costs depend on the same problem + for cost in self.costs: + if hasattr(cost, "problem") and cost.problem is not self.costs[0].problem: + self._different_problems = True + + if not self._different_problems: + super().__init__(self.costs[0].problem) + self._fixed_problem = self.costs[0]._fixed_problem + else: + super().__init__() + self._fixed_problem = False + for cost in self.costs: + self.parameters.join(cost.parameters) + + def _evaluate(self, inputs: Inputs, grad=None): + """ + Calculate the weighted cost for a given set of parameters. + + Parameters + ---------- + inputs : Inputs + The parameters for which to compute the cost. + grad : array-like, optional + An array to store the gradient of the cost function with respect + to the parameters. + + Returns + ------- + float + The weighted cost value. + """ + e = np.empty_like(self.costs) + + if not self._fixed_problem and self._different_problems: + self.parameters.update(values=list(inputs.values())) + elif not self._fixed_problem: + self._current_prediction = self.problem.evaluate(inputs) + + for i, cost in enumerate(self.costs): + if not self._fixed_problem and self._different_problems: + inputs = cost.parameters.as_dict() + cost._current_prediction = cost.problem.evaluate(inputs) + else: + cost._current_prediction = self._current_prediction + e[i] = cost._evaluate(inputs, grad) + + return np.dot(e, self.weights) + + def _evaluateS1(self, inputs: Inputs): + """ + Compute the weighted cost and its gradient with respect to the parameters. + + Parameters + ---------- + inputs : Inputs + The parameters for which to compute the cost and gradient. + + Returns + ------- + tuple + A tuple containing the cost and the gradient. The cost is a float, + and the gradient is an array-like of the same length as `x`. + """ + e = np.empty_like(self.costs) + de = np.empty((len(self.parameters), len(self.costs))) + + if not self._fixed_problem and self._different_problems: + self.parameters.update(values=list(inputs.values())) + elif not self._fixed_problem: + self._current_prediction, self._current_sensitivities = ( + self.problem.evaluateS1(inputs) + ) + + for i, cost in enumerate(self.costs): + if not self._fixed_problem and self._different_problems: + inputs = cost.parameters.as_dict() + cost._current_prediction, cost._current_sensitivities = ( + cost.problem.evaluateS1(inputs) + ) + else: + cost._current_prediction, cost._current_sensitivities = ( + self._current_prediction, + self._current_sensitivities, + ) + e[i], de[:, i] = cost._evaluateS1(inputs) + + e = np.dot(e, self.weights) + de = np.dot(de, self.weights) + + return e, de diff --git a/pybop/costs/base_cost.py b/pybop/costs/base_cost.py index d40bbb99..eedbbc2c 100644 --- a/pybop/costs/base_cost.py +++ b/pybop/costs/base_cost.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Optional, Union from pybop import BaseProblem from pybop.parameters.parameter import Inputs, Parameters @@ -22,17 +22,25 @@ class BaseCost: An array containing the target data to fit. n_outputs : int The number of outputs in the model. + + Additional Attributes + --------------------- + _fixed_problem : bool + If True, the problem does not need to be rebuilt before the cost is + calculated (default: False). """ - def __init__(self, problem=None): + def __init__(self, problem: Optional[BaseProblem] = None): self.parameters = Parameters() self.problem = problem + self._fixed_problem = False self.set_fail_gradient() if isinstance(self.problem, BaseProblem): self._target = self.problem._target self.parameters.join(self.problem.parameters) self.n_outputs = self.problem.n_outputs self.signal = self.problem.signal + self._fixed_problem = True @property def n_parameters(self): @@ -69,6 +77,9 @@ def evaluate(self, inputs: Union[Inputs, list], grad=None): inputs = self.parameters.verify(inputs) try: + if self._fixed_problem: + self._current_prediction = self.problem.evaluate(inputs) + return self._evaluate(inputs, grad) except NotImplementedError as e: @@ -126,6 +137,11 @@ def evaluateS1(self, inputs: Union[Inputs, list]): inputs = self.parameters.verify(inputs) try: + if self._fixed_problem: + self._current_prediction, self._current_sensitivities = ( + self.problem.evaluateS1(inputs) + ) + return self._evaluateS1(inputs) except NotImplementedError as e: diff --git a/pybop/costs/design_costs.py b/pybop/costs/design_costs.py index ac8ecaca..738dfe61 100644 --- a/pybop/costs/design_costs.py +++ b/pybop/costs/design_costs.py @@ -98,6 +98,7 @@ class GravimetricEnergyDensity(DesignCost): def __init__(self, problem, update_capacity=False): super().__init__(problem, update_capacity) + self._fixed_problem = False # keep problem evaluation within _evaluate def _evaluate(self, inputs: Inputs, grad=None): """ @@ -154,6 +155,7 @@ class VolumetricEnergyDensity(DesignCost): def __init__(self, problem, update_capacity=False): super().__init__(problem, update_capacity) + self._fixed_problem = False # keep problem evaluation within _evaluate def _evaluate(self, inputs: Inputs, grad=None): """ diff --git a/pybop/costs/fitting_costs.py b/pybop/costs/fitting_costs.py index ac17f3ea..18cc752d 100644 --- a/pybop/costs/fitting_costs.py +++ b/pybop/costs/fitting_costs.py @@ -38,14 +38,16 @@ def _evaluate(self, inputs: Inputs, grad=None): The root mean square error. """ - prediction = self.problem.evaluate(inputs) - - if not self.verify_prediction(prediction): + if not self.verify_prediction(self._current_prediction): return np.inf e = np.asarray( [ - np.sqrt(np.mean((prediction[signal] - self._target[signal]) ** 2)) + np.sqrt( + np.mean( + (self._current_prediction[signal] - self._target[signal]) ** 2 + ) + ) for signal in self.signal ] ) @@ -72,13 +74,19 @@ def _evaluateS1(self, inputs: Inputs): ValueError If an error occurs during the calculation of the cost or gradient. """ - y, dy = self.problem.evaluateS1(inputs) - if not self.verify_prediction(y): + if not self.verify_prediction(self._current_prediction): return np.inf, self._de * np.ones(self.n_parameters) - r = np.asarray([y[signal] - self._target[signal] for signal in self.signal]) + r = np.asarray( + [ + self._current_prediction[signal] - self._target[signal] + for signal in self.signal + ] + ) e = np.sqrt(np.mean(r**2, axis=1)) - de = np.mean((r * dy.T), axis=2) / (e + np.finfo(float).eps) + de = np.mean((r * self._current_sensitivities.T), axis=2) / ( + e + np.finfo(float).eps + ) if self.n_outputs == 1: return e.item(), de.flatten() @@ -124,14 +132,12 @@ def _evaluate(self, inputs: Inputs, grad=None): float The Sum of Squared Error. """ - prediction = self.problem.evaluate(inputs) - - if not self.verify_prediction(prediction): + if not self.verify_prediction(self._current_prediction): return np.inf e = np.asarray( [ - np.sum((prediction[signal] - self._target[signal]) ** 2) + np.sum((self._current_prediction[signal] - self._target[signal]) ** 2) for signal in self.signal ] ) @@ -158,13 +164,17 @@ def _evaluateS1(self, inputs: Inputs): ValueError If an error occurs during the calculation of the cost or gradient. """ - y, dy = self.problem.evaluateS1(inputs) - if not self.verify_prediction(y): + if not self.verify_prediction(self._current_prediction): return np.inf, self._de * np.ones(self.n_parameters) - r = np.asarray([y[signal] - self._target[signal] for signal in self.signal]) + r = np.asarray( + [ + self._current_prediction[signal] - self._target[signal] + for signal in self.signal + ] + ) e = np.sum(np.sum(r**2, axis=0), axis=0) - de = 2 * np.sum(np.sum((r * dy.T), axis=2), axis=1) + de = 2 * np.sum(np.sum((r * self._current_sensitivities.T), axis=2), axis=1) return e, de @@ -224,13 +234,15 @@ def _evaluate(self, inputs: Inputs, grad=None): float The Minkowski cost. """ - prediction = self.problem.evaluate(inputs) - if not self.verify_prediction(prediction): + if not self.verify_prediction(self._current_prediction): return np.inf e = np.asarray( [ - np.sum(np.abs(prediction[signal] - self._target[signal]) ** self.p) + np.sum( + np.abs(self._current_prediction[signal] - self._target[signal]) + ** self.p + ) ** (1 / self.p) for signal in self.signal ] @@ -258,20 +270,27 @@ def _evaluateS1(self, inputs): ValueError If an error occurs during the calculation of the cost or gradient. """ - y, dy = self.problem.evaluateS1(inputs) - if not self.verify_prediction(y): + if not self.verify_prediction(self._current_prediction): return np.inf, self._de * np.ones(self.n_parameters) - r = np.asarray([y[signal] - self._target[signal] for signal in self.signal]) + r = np.asarray( + [ + self._current_prediction[signal] - self._target[signal] + for signal in self.signal + ] + ) e = np.asarray( [ - np.sum(np.abs(y[signal] - self._target[signal]) ** self.p) + np.sum( + np.abs(self._current_prediction[signal] - self._target[signal]) + ** self.p + ) ** (1 / self.p) for signal in self.signal ] ) de = np.sum( - np.sum(r ** (self.p - 1) * dy.T, axis=2) + np.sum(r ** (self.p - 1) * self._current_sensitivities.T, axis=2) / (e ** (self.p - 1) + np.finfo(float).eps), axis=1, ) @@ -331,13 +350,15 @@ def _evaluate(self, inputs: Inputs, grad=None): float The Sum of Power cost. """ - prediction = self.problem.evaluate(inputs) - if not self.verify_prediction(prediction): + if not self.verify_prediction(self._current_prediction): return np.inf e = np.asarray( [ - np.sum(np.abs(prediction[signal] - self._target[signal]) ** self.p) + np.sum( + np.abs(self._current_prediction[signal] - self._target[signal]) + ** self.p + ) for signal in self.signal ] ) @@ -364,13 +385,19 @@ def _evaluateS1(self, inputs): ValueError If an error occurs during the calculation of the cost or gradient. """ - y, dy = self.problem.evaluateS1(inputs) - if not self.verify_prediction(y): + if not self.verify_prediction(self._current_prediction): return np.inf, self._de * np.ones(self.n_parameters) - r = np.asarray([y[signal] - self._target[signal] for signal in self.signal]) + r = np.asarray( + [ + self._current_prediction[signal] - self._target[signal] + for signal in self.signal + ] + ) e = np.sum(np.sum(np.abs(r) ** self.p)) - de = self.p * np.sum(np.sum(r ** (self.p - 1) * dy.T, axis=2), axis=1) + de = self.p * np.sum( + np.sum(r ** (self.p - 1) * self._current_sensitivities.T, axis=2), axis=1 + ) return e, de @@ -389,6 +416,7 @@ class ObserverCost(BaseCost): def __init__(self, observer: Observer): super().__init__(problem=observer) self._observer = observer + self._fixed_problem = False # keep problem evaluation within _evaluate def _evaluate(self, inputs: Inputs, grad=None): """ @@ -412,7 +440,7 @@ def _evaluate(self, inputs: Inputs, grad=None): ) return -log_likelihood - def evaluateS1(self, inputs: Inputs): + def _evaluateS1(self, inputs: Inputs): """ Compute the cost and its gradient with respect to the parameters. diff --git a/tests/unit/test_cost.py b/tests/unit/test_cost.py index 6a7d1a90..802accf1 100644 --- a/tests/unit/test_cost.py +++ b/tests/unit/test_cost.py @@ -1,3 +1,5 @@ +from copy import copy + import numpy as np import pytest @@ -250,6 +252,12 @@ def test_SumofPower(self, problem): with pytest.raises(ValueError, match="p = np.inf is not yet supported."): pybop.SumofPower(problem, p=np.inf) + @pytest.fixture + def design_problem(self, model, parameters, experiment, signal): + return pybop.DesignProblem( + model, parameters, experiment, signal=signal, init_soc=0.5 + ) + @pytest.mark.parametrize( "cost_class", [ @@ -259,21 +267,9 @@ def test_SumofPower(self, problem): ], ) @pytest.mark.unit - def test_design_costs( - self, - cost_class, - model, - parameters, - experiment, - signal, - ): - # Construct Problem - problem = pybop.DesignProblem( - model, parameters, experiment, signal=signal, init_soc=0.5 - ) - + def test_design_costs(self, cost_class, design_problem): # Construct Cost - cost = cost_class(problem) + cost = cost_class(design_problem) if cost_class in [pybop.DesignCost]: with pytest.raises(NotImplementedError): @@ -299,5 +295,80 @@ def test_design_costs( cost(["StringInputShouldNotWork"]) # Compute after updating nominal capacity - cost = cost_class(problem, update_capacity=True) + cost = cost_class(design_problem, update_capacity=True) cost([0.4]) + + @pytest.mark.unit + def test_weighted_fitting_cost(self, problem): + cost1 = pybop.SumSquaredError(problem) + cost2 = pybop.RootMeanSquaredError(problem) + + # Test with and without weights + weighted_cost = pybop.WeightedCost(cost1, cost2) + np.testing.assert_array_equal(weighted_cost.weights, np.ones(2)) + weighted_cost = pybop.WeightedCost(cost1, cost2, weights=[1, 1]) + np.testing.assert_array_equal(weighted_cost.weights, np.ones(2)) + weighted_cost = pybop.WeightedCost(cost1, cost2, weights=np.array([1, 1])) + np.testing.assert_array_equal(weighted_cost.weights, np.ones(2)) + with pytest.raises( + TypeError, + match=r"Received instead of cost object.", + ): + weighted_cost = pybop.WeightedCost("Invalid string") + with pytest.raises( + TypeError, + match="Expected a list or array of weights the same length as costs.", + ): + weighted_cost = pybop.WeightedCost(cost1, cost2, weights="Invalid string") + with pytest.raises( + ValueError, + match="Expected a list or array of weights the same length as costs.", + ): + weighted_cost = pybop.WeightedCost(cost1, cost2, weights=[1]) + + # Test with and without different problems + weight = 100 + weighted_cost_2 = pybop.WeightedCost(cost1, cost2, weights=[1, weight]) + assert weighted_cost_2._different_problems is False + assert weighted_cost_2._fixed_problem is True + assert weighted_cost_2.problem is problem + assert weighted_cost_2([0.5]) >= 0 + np.testing.assert_allclose( + weighted_cost_2.evaluate([0.6]), + cost1([0.6]) + weight * cost2([0.6]), + atol=1e-5, + ) + + cost3 = pybop.RootMeanSquaredError(copy(problem)) + weighted_cost_3 = pybop.WeightedCost(cost1, cost3, weights=[1, weight]) + assert weighted_cost_3._different_problems is True + assert weighted_cost_3._fixed_problem is False + assert weighted_cost_3.problem is None + assert weighted_cost_3([0.5]) >= 0 + np.testing.assert_allclose( + weighted_cost_3.evaluate([0.6]), + cost1([0.6]) + weight * cost3([0.6]), + atol=1e-5, + ) + + errors_2, sensitivities_2 = weighted_cost_2.evaluateS1([0.5]) + errors_3, sensitivities_3 = weighted_cost_3.evaluateS1([0.5]) + np.testing.assert_allclose(errors_2, errors_3, atol=1e-5) + np.testing.assert_allclose(sensitivities_2, sensitivities_3, atol=1e-5) + + @pytest.mark.unit + def test_weighted_design_cost(self, design_problem): + cost1 = pybop.GravimetricEnergyDensity(design_problem) + cost2 = pybop.RootMeanSquaredError(design_problem) + + # Test with and without weights + weighted_cost = pybop.WeightedCost(cost1, cost2) + assert weighted_cost._different_problems is False + assert weighted_cost._fixed_problem is False + assert weighted_cost.problem is design_problem + assert weighted_cost([0.5]) >= 0 + np.testing.assert_allclose( + weighted_cost.evaluate([0.6]), + cost1([0.6]) + cost2([0.6]), + atol=1e-5, + ) From d9b718b47fda338f3e4bc50eebc8f75a2dd768e1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Jul 2024 18:04:18 +0000 Subject: [PATCH 13/13] chore: update pre-commit hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.5.2 → v0.5.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.2...v0.5.4) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 28055658..2cc81860 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.5.2" + rev: "v0.5.4" hooks: - id: ruff args: [--fix, --show-fixes]