diff --git a/CHANGELOG.md b/CHANGELOG.md index b85cb236..f7d7c44a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Features +- [#79](https://github.com/pybop-team/PyBOP/issues/79) - Adds BPX as a dependency and imports BPX support from PyBaMM. - [#267](https://github.com/pybop-team/PyBOP/pull/267) - Add classifiers to pyproject.toml, update project.urls. - [#195](https://github.com/pybop-team/PyBOP/issues/195) - Adds the Nelder-Mead optimiser from PINTS as another option. diff --git a/examples/notebooks/equivalent_circuit_identification.ipynb b/examples/notebooks/equivalent_circuit_identification.ipynb index d905569c..8f40ab12 100644 --- a/examples/notebooks/equivalent_circuit_identification.ipynb +++ b/examples/notebooks/equivalent_circuit_identification.ipynb @@ -94,9 +94,10 @@ "metadata": {}, "outputs": [], "source": [ - "# params = pybop.ParameterSet(\n", + "# parameter_set = pybop.ParameterSet(\n", "# json_path=\"examples/scripts/parameters/initial_ecm_parameters.json\"\n", - "# )" + "# )\n", + "# parameter_set.import_parameters()" ] }, { @@ -116,7 +117,7 @@ "metadata": {}, "outputs": [], "source": [ - "params = pybop.ParameterSet(\n", + "parameter_set = pybop.ParameterSet(\n", " params_dict={\n", " \"chemistry\": \"ecm\",\n", " \"Initial SoC\": 0.5,\n", @@ -162,7 +163,7 @@ "outputs": [], "source": [ "model = pybop.empirical.Thevenin(\n", - " parameter_set=params.import_parameters(), options={\"number of rc elements\": 2}\n", + " parameter_set=parameter_set, options={\"number of rc elements\": 2}\n", ")" ] }, diff --git a/examples/scripts/BPX_spm.py b/examples/scripts/BPX_spm.py new file mode 100644 index 00000000..d77fdae0 --- /dev/null +++ b/examples/scripts/BPX_spm.py @@ -0,0 +1,70 @@ +import numpy as np + +import pybop + +# Define model +bpx_parameters = pybop.ParameterSet( + json_path="examples/scripts/parameters/example_BPX.json" +) +parameter_set = bpx_parameters.import_from_bpx() +model = pybop.lithium_ion.SPM(parameter_set=parameter_set) + +# Fitting parameters +parameters = [ + pybop.Parameter( + "Negative particle radius [m]", + prior=pybop.Gaussian(6e-06, 0.1e-6), + bounds=[1e-6, 9e-6], + true_value=parameter_set["Negative particle radius [m]"], + ), + pybop.Parameter( + "Positive particle radius [m]", + prior=pybop.Gaussian(4.5e-07, 0.1e-6), + bounds=[1e-7, 9e-7], + true_value=parameter_set["Positive particle radius [m]"], + ), +] + +# Generate data +sigma = 0.001 +t_eval = np.arange(0, 900, 2) +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) +cost = pybop.SumSquaredError(problem) +optim = pybop.Optimisation(cost, optimiser=pybop.CMAES) +optim.set_max_iterations(100) + +# Run the optimisation +x, final_cost = optim.run() +print( + "True parameters:", + [ + parameters[0].true_value, + parameters[1].true_value, + ], +) +print("Estimated parameters:", x) + +# Plot the timeseries output +pybop.quick_plot(problem, parameter_values=x, title="Optimised Comparison") + +# Plot convergence +pybop.plot_convergence(optim) + +# Plot the parameter traces +pybop.plot_parameters(optim) + +# Plot the cost landscape with optimisation path and updated bounds +pybop.plot2d(optim, steps=15) diff --git a/examples/scripts/ecm_CMAES.py b/examples/scripts/ecm_CMAES.py index 6e9dd299..6ba31f99 100644 --- a/examples/scripts/ecm_CMAES.py +++ b/examples/scripts/ecm_CMAES.py @@ -3,13 +3,14 @@ import pybop # Import the ECM parameter set from JSON -params = pybop.ParameterSet( +parameter_set = pybop.ParameterSet( json_path="examples/scripts/parameters/initial_ecm_parameters.json" ) +parameter_set.import_parameters() # Alternatively, define the initial parameter set with a dictionary # Add definitions for R's, C's, and initial overpotentials for any additional RC elements -# params = pybop.ParameterSet( +# parameter_set = pybop.ParameterSet( # params_dict={ # "chemistry": "ecm", # "Initial SoC": 0.5, @@ -40,7 +41,7 @@ # Define the model model = pybop.empirical.Thevenin( - parameter_set=params.import_parameters(), options={"number of rc elements": 2} + parameter_set=parameter_set, options={"number of rc elements": 2} ) # Fitting parameters @@ -81,7 +82,7 @@ print("Estimated parameters:", x) # Export the parameters to JSON -params.export_parameters( +parameter_set.export_parameters( "examples/scripts/parameters/fit_ecm_parameters.json", fit_params=parameters ) diff --git a/examples/scripts/parameters/example_BPX.json b/examples/scripts/parameters/example_BPX.json new file mode 100644 index 00000000..43bbcf90 --- /dev/null +++ b/examples/scripts/parameters/example_BPX.json @@ -0,0 +1,78 @@ +{ + "Header": { + "BPX": 0.1, + "Title": "Parameterisation example of an LFP|graphite 2 Ah cylindrical 18650 cell.", + "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" + }, + "Parameterisation": { + "Cell": { + "Ambient temperature [K]": 298.15, + "Initial temperature [K]": 298.15, + "Reference temperature [K]": 298.15, + "Lower voltage cut-off [V]": 2.0, + "Upper voltage cut-off [V]": 3.65, + "Nominal cell capacity [A.h]": 2, + "Specific heat capacity [J.K-1.kg-1]": 999, + "Thermal conductivity [W.m-1.K-1]": 1.89, + "Density [kg.m-3]": 1940, + "Electrode area [m2]": 0.08959998, + "Number of electrode pairs connected in parallel to make a cell": 1, + "External surface area [m2]": 0.00431, + "Volume [m3]": 1.7e-05 + }, + "Electrolyte": { + "Initial concentration [mol.m-3]": 1000, + "Cation transference number": 0.259, + "Conductivity [S.m-1]": "0.1297 * (x / 1000) ** 3 - 2.51 * (x / 1000) ** 1.5 + 3.329 * (x / 1000)", + "Diffusivity [m2.s-1]": "8.794e-11 * (x / 1000) ** 2 - 3.972e-10 * (x / 1000) + 4.862e-10", + "Conductivity activation energy [J.mol-1]": 17100, + "Diffusivity activation energy [J.mol-1]": 17100 + }, + "Negative electrode": { + "Particle radius [m]": 4.8e-06, + "Thickness [m]": 4.44e-05, + "Diffusivity [m2.s-1]": 9.6e-15, + "OCP [V]": "5.29210878e+01 * exp(-1.72699386e+02 * x) - 1.17963399e+03 + 1.20956356e+03 * tanh(6.72033948e+01 * (x + 2.44746396e-02)) + 4.52430314e-02 * tanh(-1.47542326e+01 * (x - 1.62746053e-01)) + 2.01855800e+01 * tanh(-2.46666302e+01 * (x - 1.12986136e+00)) + 2.01708039e-02 * tanh(-1.19900231e+01 * (x - 5.49773440e-01)) + 4.99616805e+01 * tanh(-6.11370883e+01 * (x + 4.69382558e-03))", + "Entropic change coefficient [V.K-1]": "(-0.1112 * x + 0.02914 + 0.3561 * exp(-((x - 0.08309) ** 2) / 0.004616)) / 1000", + "Conductivity [S.m-1]": 7.46, + "Surface area per unit volume [m-1]": 473004, + "Porosity": 0.20666, + "Transport efficiency": 0.09395, + "Reaction rate constant [mol.m-2.s-1]": 6.872e-06, + "Minimum stoichiometry": 0.0016261, + "Maximum stoichiometry": 0.82258, + "Maximum concentration [mol.m-3]": 31400, + "Diffusivity activation energy [J.mol-1]": 30000, + "Reaction rate constant activation energy [J.mol-1]": 55000 + }, + "Positive electrode": { + "Particle radius [m]": 5e-07, + "Thickness [m]": 6.43e-05, + "Diffusivity [m2.s-1]": 6.873e-17, + "OCP [V]": "3.41285712e+00 - 1.49721852e-02 * x + 3.54866018e+14 * exp(-3.95729493e+02 * x) - 1.45998465e+00 * exp(-1.10108622e+02 * (1 - x))", + "Entropic change coefficient [V.K-1]": { + "x": [0, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1], + "y": [0.0001, 4.7145e-05, 3.7666e-05, 2.0299e-05, 5.9833e-06, -4.6859e-06, -1.3966e-05, -2.3528e-05, -3.3593e-05, -4.3433e-05, -5.2311e-05, -6.0211e-05, -6.8006e-05, -7.6939e-05, -8.7641e-05, -9.913e-05, -0.00010855, -0.00011266, -0.00011238, -0.00010921, -0.00022539] + }, + "Conductivity [S.m-1]": 0.80, + "Surface area per unit volume [m-1]": 4418460, + "Porosity": 0.20359, + "Transport efficiency": 0.09186, + "Reaction rate constant [mol.m-2.s-1]": 9.736e-07, + "Minimum stoichiometry": 0.0875, + "Maximum stoichiometry": 0.95038, + "Maximum concentration [mol.m-3]": 21200, + "Diffusivity activation energy [J.mol-1]": 80000, + "Reaction rate constant activation energy [J.mol-1]": 35000 + }, + "Separator": { + "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/models/base_model.py b/pybop/models/base_model.py index 47709cec..ba570170 100644 --- a/pybop/models/base_model.py +++ b/pybop/models/base_model.py @@ -14,7 +14,7 @@ @dataclass class TimeSeriesState(object): """ - The current state of a time series model that is a pybamm model + The current state of a time series model that is a pybamm model. """ sol: pybamm.Solution @@ -46,7 +46,7 @@ class BaseModel: """ - def __init__(self, name="Base Model"): + def __init__(self, name="Base Model", parameter_set=None): """ Initialize the BaseModel with an optional name. @@ -56,6 +56,15 @@ def __init__(self, name="Base Model"): The name given to the model instance. """ self.name = name + if parameter_set is None: + self._parameter_set = None + elif isinstance(parameter_set, dict): + self._parameter_set = pybamm.ParameterValues(parameter_set) + elif isinstance(parameter_set, pybamm.ParameterValues): + self._parameter_set = parameter_set + else: # a pybop parameter set + self._parameter_set = pybamm.ParameterValues(parameter_set.params) + self.pybamm_model = None self.parameters = None self.dataset = None diff --git a/pybop/models/empirical/ecm.py b/pybop/models/empirical/ecm.py index 6926c4d5..4d7290df 100644 --- a/pybop/models/empirical/ecm.py +++ b/pybop/models/empirical/ecm.py @@ -45,24 +45,21 @@ def __init__( options=None, **kwargs, ): - super().__init__() + super().__init__(name, parameter_set) self.pybamm_model = pybamm.equivalent_circuit.Thevenin( options=options, **kwargs ) self._unprocessed_model = self.pybamm_model - self.name = name - if isinstance(parameter_set, dict): - self.default_parameter_values = pybamm.ParameterValues(parameter_set) - self._parameter_set = self.default_parameter_values + # Set parameters, using either the provided ones or the default + if isinstance(self._parameter_set, dict): + self.default_parameter_values = pybamm.ParameterValues(self._parameter_set) else: self.default_parameter_values = self.pybamm_model.default_parameter_values - self._parameter_set = ( - parameter_set or self.pybamm_model.default_parameter_values - ) - + self._parameter_set = self._parameter_set or self.default_parameter_values self._unprocessed_parameter_set = self._parameter_set + # Define model geometry and discretization self.geometry = geometry or self.pybamm_model.default_geometry self.submesh_types = submesh_types or self.pybamm_model.default_submesh_types self.var_pts = var_pts or self.pybamm_model.default_var_pts @@ -71,6 +68,7 @@ def __init__( ) self.solver = solver or self.pybamm_model.default_solver + # Internal attributes for the built model are initialized but not set self._model_with_set_params = None self._built_model = None self._built_initial_soc = None diff --git a/pybop/models/empirical/ecm_base.py b/pybop/models/empirical/ecm_base.py index b66fb47b..e21a09e5 100644 --- a/pybop/models/empirical/ecm_base.py +++ b/pybop/models/empirical/ecm_base.py @@ -6,8 +6,8 @@ class ECircuitModel(BaseModel): Overwrites and extends `BaseModel` class for circuit-based PyBaMM models. """ - def __init__(self): - super().__init__() + def __init__(self, name, parameter_set): + super().__init__(name, parameter_set) def _check_params(self, inputs=None, allow_infeasible_solutions=True): """ diff --git a/pybop/models/lithium_ion/echem.py b/pybop/models/lithium_ion/echem.py index a690d0de..c8ad861d 100644 --- a/pybop/models/lithium_ion/echem.py +++ b/pybop/models/lithium_ion/echem.py @@ -41,16 +41,13 @@ def __init__( solver=None, options=None, ): - super().__init__() + super().__init__(name, parameter_set) self.pybamm_model = pybamm.lithium_ion.SPM(options=options) self._unprocessed_model = self.pybamm_model - self.name = name # Set parameters, using either the provided ones or the default self.default_parameter_values = self.pybamm_model.default_parameter_values - self._parameter_set = ( - parameter_set or self.pybamm_model.default_parameter_values - ) + self._parameter_set = self._parameter_set or self.default_parameter_values self._unprocessed_parameter_set = self._parameter_set # Define model geometry and discretization @@ -113,16 +110,13 @@ def __init__( solver=None, options=None, ): - super().__init__() + super().__init__(name, parameter_set) self.pybamm_model = pybamm.lithium_ion.SPMe(options=options) self._unprocessed_model = self.pybamm_model - self.name = name # Set parameters, using either the provided ones or the default self.default_parameter_values = self.pybamm_model.default_parameter_values - self._parameter_set = ( - parameter_set or self.pybamm_model.default_parameter_values - ) + self._parameter_set = self._parameter_set or self.default_parameter_values self._unprocessed_parameter_set = self._parameter_set # Define model geometry and discretization diff --git a/pybop/models/lithium_ion/echem_base.py b/pybop/models/lithium_ion/echem_base.py index 43258743..677623d6 100644 --- a/pybop/models/lithium_ion/echem_base.py +++ b/pybop/models/lithium_ion/echem_base.py @@ -8,8 +8,8 @@ class EChemBaseModel(BaseModel): Overwrites and extends `BaseModel` class for electrochemical PyBaMM models. """ - def __init__(self): - super().__init__() + def __init__(self, name, parameter_set): + super().__init__(name, parameter_set) def _check_params( self, inputs=None, parameter_set=None, allow_infeasible_solutions=True diff --git a/pybop/parameters/parameter_set.py b/pybop/parameters/parameter_set.py index 4f704bf4..e282a1a5 100644 --- a/pybop/parameters/parameter_set.py +++ b/pybop/parameters/parameter_set.py @@ -27,6 +27,9 @@ def __init__(self, json_path=None, params_dict=None): self.params = params_dict or {} self.chemistry = None + def __call__(self): + return self.params + def import_parameters(self, json_path=None): """ Imports parameters from a JSON file specified by the `json_path` attribute. @@ -56,10 +59,48 @@ def import_parameters(self, json_path=None): with open(self.json_path, "r") as file: self.params = json.load(file) self._handle_special_cases() + else: + raise ValueError( + "Parameter set already constructed, or path to json file not provided." + ) if self.params["chemistry"] is not None: self.chemistry = self.params["chemistry"] return self.params + def import_from_bpx(self, json_path=None): + """ + Imports parameters from a JSON file in the BPX format specified by the `json_path` + attribute. + Credit: PyBaMM + + If a `json_path` is provided at initialization or as an argument, that JSON file + is loaded and the parameters are stored in the `params` attribute. + + Parameters + ---------- + json_path : str, optional + Path to the JSON file from which to import parameters. If provided, it overrides the instance's `json_path`. + + Returns + ------- + dict + The dictionary containing the imported parameters. + + Raises + ------ + FileNotFoundError + If the specified JSON file cannot be found. + """ + + # Read JSON file + if not self.params and self.json_path: + self.params = pybamm.ParameterValues.create_from_bpx(self.json_path) + else: + raise ValueError( + "Parameter set already constructed, or path to bpx file not provided." + ) + return self.params + def _handle_special_cases(self): """ Processes special cases for parameter values that require custom handling. diff --git a/pyproject.toml b/pyproject.toml index 976073b9..df1b1ab9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "numpy>=1.16", "scipy>=1.3", "pints>=0.5", + "bpx>=0.4", ] [project.optional-dependencies] diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 6d343063..a4b5cfea 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -114,6 +114,24 @@ def test_rebuild(self, model): initial_built_model, attribute ) + @pytest.mark.unit + def test_parameter_set_definition(self): + # Test initilisation with different types of parameter set + param_dict = {"Nominal cell capacity [A.h]": 5} + model = pybop.BaseModel(parameter_set=None) + assert model._parameter_set is None + + model = pybop.BaseModel(parameter_set=param_dict) + parameter_set = pybamm.ParameterValues(param_dict) + assert model._parameter_set == parameter_set + + model = pybop.BaseModel(parameter_set=parameter_set) + assert model._parameter_set == parameter_set + + pybop_parameter_set = pybop.ParameterSet(params_dict=param_dict) + model = pybop.BaseModel(parameter_set=pybop_parameter_set) + assert model._parameter_set == parameter_set + @pytest.mark.unit def test_rebuild_geometric_parameters(self): parameter_set = pybop.ParameterSet.pybamm("Chen2020") diff --git a/tests/unit/test_parameter_sets.py b/tests/unit/test_parameter_sets.py index 931d391a..f24e7bea 100644 --- a/tests/unit/test_parameter_sets.py +++ b/tests/unit/test_parameter_sets.py @@ -23,11 +23,24 @@ def test_parameter_set(self): @pytest.mark.unit def test_ecm_parameter_sets(self): # Test importing a json file + json_params = pybop.ParameterSet() + with pytest.raises( + ValueError, + match="Parameter set already constructed, or path to json file not provided.", + ): + json_params.import_parameters() + json_params = pybop.ParameterSet( json_path="examples/scripts/parameters/initial_ecm_parameters.json" ) json_params.import_parameters() + with pytest.raises( + ValueError, + match="Parameter set already constructed, or path to json file not provided.", + ): + json_params.import_parameters() + params = pybop.ParameterSet( params_dict={ "chemistry": "ecm", @@ -56,9 +69,9 @@ def test_ecm_parameter_sets(self): "Entropic change [V/K]": 0.0004, } ) - params.import_parameters() assert json_params.params == params.params + assert params() == params.params # Test exporting a json file parameters = [ @@ -85,3 +98,24 @@ def test_ecm_parameter_sets(self): empty_params.export_parameters( "examples/scripts/parameters/fit_ecm_parameters.json" ) + + @pytest.mark.unit + def test_bpx_parameter_sets(self): + # Test importing a BPX json file + bpx_parameters = pybop.ParameterSet() + with pytest.raises( + ValueError, + match="Parameter set already constructed, or path to bpx file not provided.", + ): + bpx_parameters.import_from_bpx() + + bpx_parameters = pybop.ParameterSet( + json_path="examples/scripts/parameters/example_BPX.json" + ) + bpx_parameters.import_from_bpx() + + with pytest.raises( + ValueError, + match="Parameter set already constructed, or path to bpx file not provided.", + ): + bpx_parameters.import_from_bpx()