Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add design optimisation example #149

Merged
merged 25 commits into from
Jan 8, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
18c77d2
Lower parameter range
NicolaCourtier Dec 14, 2023
1cd6732
Lower test parameters
NicolaCourtier Dec 14, 2023
22526a9
Add model-specific parameter checks
NicolaCourtier Dec 14, 2023
e55eb81
Wrap cost functions for error checking
NicolaCourtier Dec 14, 2023
985fc19
Add electrode SOH functionality to echem
NicolaCourtier Dec 15, 2023
40ec760
Use predict for evaluating a DesignProblem
NicolaCourtier Dec 15, 2023
d45463c
Update plotting bounds
NicolaCourtier Dec 15, 2023
5e5c85e
Add design optimisation example
NicolaCourtier Dec 15, 2023
30520f9
Merge branch 'develop' into 38b-design-example
NicolaCourtier Dec 15, 2023
c444690
style: pre-commit fixes
pre-commit-ci[bot] Dec 15, 2023
b7d3125
Set SciPy pop_size and relax two_signal assertion
NicolaCourtier Dec 15, 2023
85ec240
Remove unused dependency
NicolaCourtier Dec 15, 2023
546a1be
Merge branch '38b-design-example' of https://github.com/pybop-team/Py…
NicolaCourtier Dec 15, 2023
9384bc7
Switch SciPy test
NicolaCourtier Dec 15, 2023
97606f0
Reduce SciPy pop size
NicolaCourtier Dec 15, 2023
7c9bb44
Scale cost for SciPyMinimize
NicolaCourtier Dec 15, 2023
5d0c60f
Switch Adam to more resilient IRPropMin
NicolaCourtier Dec 15, 2023
77a66d9
Change areal mass to area density
NicolaCourtier Dec 15, 2023
f1ec4bc
style: pre-commit fixes
pre-commit-ci[bot] Dec 15, 2023
5b0335b
Update ps to parameter_set
NicolaCourtier Dec 15, 2023
9247fa1
Merge branch '38b-design-example' of https://github.com/pybop-team/Py…
NicolaCourtier Dec 15, 2023
a53b3e1
Add Experiment class and unit test
NicolaCourtier Dec 15, 2023
b900110
style: pre-commit fixes
pre-commit-ci[bot] Dec 15, 2023
2578c36
Update comment
NicolaCourtier Dec 15, 2023
112139d
Add calc nom. voltage from half-cell ocps, np.trapz dx variable, prin…
BradyPlanden Dec 19, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- [#114](https://github.com/pybop-team/PyBOP/issues/114) - Adds a SciPy minimize example and logging for non-Pints optimisers
- [#116](https://github.com/pybop-team/PyBOP/issues/116) - Adds PSO, SNES, XNES, ADAM, and IPropMin optimisers to PintsOptimisers() class
- [#38](https://github.com/pybop-team/PyBOP/issues/38) - Restructures the Problem classes ahead of adding a design optimisation example
- [#38](https://github.com/pybop-team/PyBOP/issues/38) - Updates tests and adds a design optimisation example script `spme_max_energy`
- [#120](https://github.com/pybop-team/PyBOP/issues/120) - Updates the parameterisation test settings including the number of iterations
- [#145](https://github.com/pybop-team/PyBOP/issues/145) - Reformats Dataset to contain a dictionary and signal into a list of strings

Expand Down
10 changes: 5 additions & 5 deletions examples/scripts/spm_CMAES.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@
parameters = [
pybop.Parameter(
"Negative electrode active material volume fraction",
prior=pybop.Gaussian(0.7, 0.05),
bounds=[0.6, 0.9],
prior=pybop.Gaussian(0.6, 0.05),
bounds=[0.5, 0.8],
),
pybop.Parameter(
"Positive electrode active material volume fraction",
prior=pybop.Gaussian(0.58, 0.05),
bounds=[0.5, 0.8],
prior=pybop.Gaussian(0.48, 0.05),
bounds=[0.4, 0.7],
),
]

Expand Down Expand Up @@ -57,5 +57,5 @@
pybop.plot_cost2d(cost, steps=15)

# Plot the cost landscape with optimisation path and updated bounds
bounds = np.array([[0.6, 0.9], [0.5, 0.8]])
bounds = np.array([[0.55, 0.75], [0.48, 0.68]])
pybop.plot_cost2d(cost, optim=optim, bounds=bounds, steps=15)
8 changes: 4 additions & 4 deletions examples/scripts/spm_IRPropMin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@
parameters = [
pybop.Parameter(
"Negative electrode active material volume fraction",
prior=pybop.Gaussian(0.7, 0.05),
bounds=[0.6, 0.9],
prior=pybop.Gaussian(0.6, 0.05),
bounds=[0.5, 0.8],
),
pybop.Parameter(
"Positive electrode active material volume fraction",
prior=pybop.Gaussian(0.58, 0.05),
bounds=[0.5, 0.8],
prior=pybop.Gaussian(0.48, 0.05),
bounds=[0.4, 0.7],
),
]

Expand Down
8 changes: 4 additions & 4 deletions examples/scripts/spm_SNES.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@
parameters = [
pybop.Parameter(
"Negative electrode active material volume fraction",
prior=pybop.Gaussian(0.7, 0.05),
bounds=[0.6, 0.9],
prior=pybop.Gaussian(0.6, 0.05),
bounds=[0.5, 0.8],
),
pybop.Parameter(
"Positive electrode active material volume fraction",
prior=pybop.Gaussian(0.58, 0.05),
bounds=[0.5, 0.8],
prior=pybop.Gaussian(0.48, 0.05),
bounds=[0.4, 0.7],
),
]

Expand Down
8 changes: 4 additions & 4 deletions examples/scripts/spm_XNES.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@
parameters = [
pybop.Parameter(
"Negative electrode active material volume fraction",
prior=pybop.Gaussian(0.7, 0.05),
bounds=[0.6, 0.9],
prior=pybop.Gaussian(0.6, 0.05),
bounds=[0.5, 0.8],
),
pybop.Parameter(
"Positive electrode active material volume fraction",
prior=pybop.Gaussian(0.58, 0.05),
bounds=[0.5, 0.8],
prior=pybop.Gaussian(0.48, 0.05),
bounds=[0.4, 0.7],
),
]

Expand Down
8 changes: 4 additions & 4 deletions examples/scripts/spm_adam.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@
parameters = [
pybop.Parameter(
"Negative electrode active material volume fraction",
prior=pybop.Gaussian(0.7, 0.05),
bounds=[0.6, 0.9],
prior=pybop.Gaussian(0.6, 0.05),
bounds=[0.5, 0.8],
),
pybop.Parameter(
"Positive electrode active material volume fraction",
prior=pybop.Gaussian(0.58, 0.05),
bounds=[0.5, 0.8],
prior=pybop.Gaussian(0.48, 0.05),
bounds=[0.4, 0.7],
),
]

Expand Down
8 changes: 4 additions & 4 deletions examples/scripts/spm_descent.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@
parameters = [
pybop.Parameter(
"Negative electrode active material volume fraction",
prior=pybop.Gaussian(0.7, 0.05),
bounds=[0.6, 0.9],
prior=pybop.Gaussian(0.6, 0.05),
bounds=[0.5, 0.8],
),
pybop.Parameter(
"Positive electrode active material volume fraction",
prior=pybop.Gaussian(0.58, 0.05),
bounds=[0.5, 0.8],
prior=pybop.Gaussian(0.48, 0.05),
bounds=[0.4, 0.7],
),
]

Expand Down
8 changes: 4 additions & 4 deletions examples/scripts/spm_nlopt.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@
parameters = [
pybop.Parameter(
"Negative electrode active material volume fraction",
prior=pybop.Gaussian(0.75, 0.05),
bounds=[0.6, 0.9],
prior=pybop.Gaussian(0.6, 0.05),
bounds=[0.5, 0.8],
),
pybop.Parameter(
"Positive electrode active material volume fraction",
prior=pybop.Gaussian(0.65, 0.05),
bounds=[0.5, 0.8],
prior=pybop.Gaussian(0.48, 0.05),
bounds=[0.4, 0.7],
),
]

Expand Down
8 changes: 4 additions & 4 deletions examples/scripts/spm_pso.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@
parameters = [
pybop.Parameter(
"Negative electrode active material volume fraction",
prior=pybop.Gaussian(0.7, 0.05),
bounds=[0.6, 0.9],
prior=pybop.Gaussian(0.6, 0.05),
bounds=[0.5, 0.8],
),
pybop.Parameter(
"Positive electrode active material volume fraction",
prior=pybop.Gaussian(0.58, 0.05),
bounds=[0.5, 0.8],
prior=pybop.Gaussian(0.48, 0.05),
bounds=[0.4, 0.7],
),
]

Expand Down
8 changes: 4 additions & 4 deletions examples/scripts/spm_scipymin.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@
parameters = [
pybop.Parameter(
"Negative electrode active material volume fraction",
prior=pybop.Gaussian(0.75, 0.05),
bounds=[0.6, 0.9],
prior=pybop.Gaussian(0.6, 0.05),
bounds=[0.5, 0.8],
),
pybop.Parameter(
"Positive electrode active material volume fraction",
prior=pybop.Gaussian(0.65, 0.05),
bounds=[0.5, 0.8],
prior=pybop.Gaussian(0.48, 0.05),
bounds=[0.4, 0.7],
),
]

Expand Down
175 changes: 175 additions & 0 deletions examples/scripts/spme_max_energy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import pybop
import pybamm
NicolaCourtier marked this conversation as resolved.
Show resolved Hide resolved
import numpy as np
import warnings

# A design optimisation example loosely based on work by L.D. Couto
# available at https://doi.org/10.1016/j.energy.2022.125966.

# The target is to maximise the gravimetric energy density over a
# range of possible design parameter values, including for example:
# cross-sectional area = height x width (only need change one)
# electrode widths, particle radii, volume fractions and
# separator width.

# Define parameter set and model
parameter_set = pybop.ParameterSet.pybamm("Chen2020")
model = pybop.lithium_ion.SPMe(parameter_set=parameter_set)

# Fitting parameters
parameters = [
pybop.Parameter(
"Positive electrode thickness [m]",
prior=pybop.Gaussian(7.56e-05, 0.05e-05),
bounds=[7e-05, 10e-05],
),
pybop.Parameter(
"Positive particle radius [m]",
prior=pybop.Gaussian(5.22e-06, 0.05e-06),
bounds=[3e-06, 9e-06],
),
]


# Define functions
def nominal_capacity(x, model):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I would prefer to have this be within a pybop class. This example would be simpler with nominal_capacity(), cell_mass(), and GravimetricEnergyDensity() in a seperate pybop class.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now, they need to be defined in the example - the definitions are not fixed and have been adjusted for the purpose of maximising the gravimetric energy density within the constraints of the model. Do you mean a class within the script?

Copy link
Member

@BradyPlanden BradyPlanden Dec 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More like the GravimetricEnergyDensity class would be within pybop.costs with the nominal_capacity and cell_mass methods inside something like a _utilities.py file. They could be then be called within this example via pybop.cell_mass() andpybop.nominal_capacity(), without needing to be defined.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As below, I don't think the definitions should be taken out of context at this stage, but I think this example is still worth having as we continue to test and develop!

"""
Update the nominal capacity based on the theoretical energy density and an
average voltage.
"""
inputs = {
key: x[i] for i, key in enumerate([param.name for param in model.parameters])
}
model._parameter_set.update(inputs)

theoretical_energy = model._electrode_soh.calculate_theoretical_energy(
model._parameter_set
)
average_voltage = (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be the mean of the output model voltage instead of the mean on the bounds? A mean value from the OCV function is an alternative if we don't have the model output at this point.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nominal capacity is required to scale the C-rate used in the experiment, so it must be computed before the simulation. However, it is only a scaling factor on the current so, as long as the same average_voltage is used each time, I believe the energy density will be maximised.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood. This implementation would result in an optimal energy density for a given applied current (scaled by the calculated nom. capacity), but this applied current wouldn't be the correct C-rate, right? I think we can get the correct mean voltage via the OCV function though.

Something like this model._parameter_set["Positive electrode OCP [V]"](mean_sto) where themean_sto variable can be calculated by taking the mean ofmodel._esoh_solver.get_min_max_stoichiometries(). More information here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I understand, there's no feasible way to compute the "correct C-rate" - this is a challenge that comes with using C-rate instead of a defined current and one that we will probably have to address more explicitly in user guidance in the future. In practice, the measured charge/discharge capacity depends on the electrode balancing as well as the overpotential dynamics. So the question becomes: how accurate does the C-rate need to be for the optimisation results to hold and by how much do we want to prioritise accuracy over speed?

The approach you suggest will certainly give a more accurate "average_voltage" but it will also increase the computational load, which is already high due to the re-building. It may also disguise the fact that we need to use an approximation here to obtain a C-rate pre-experiment. We could calculate the realistic mean once at the beginning, using the starting parameter set and inputs, but this would change the cost values from run-to-run so reproducibility could be an issue.

A fixed, easy-to-compute value avoids these issues and, while a particular voltage use-case may be more helpful, I think an average of the voltage limits is a reasonable approximation for now.

model._parameter_set["Upper voltage cut-off [V]"]
+ model._parameter_set["Lower voltage cut-off [V]"]
) / 2
theoretical_capacity = theoretical_energy / average_voltage
model._parameter_set.update({"Nominal cell capacity [A.h]": theoretical_capacity})


def cell_mass(ps):
NicolaCourtier marked this conversation as resolved.
Show resolved Hide resolved
"""
Compute the total cell mass [kg] for the current parameter set.
"""

# Approximations due to SPM(e) parameter set limitations
electrolyte_density = ps["Separator density [kg.m-3]"]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No electrolyte density variable for the SPMe, correct? Since it's just a scalar in this example, I would probably remove it instead of substituting with the separator density variable.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, we could just pull from literature for a standard EMC/DMC elecrolyte at a given molarity.


# Electrode mass densities [kg.m-3]
positive_mass_density = (
ps["Positive electrode active material volume fraction"]
* ps["Positive electrode density [kg.m-3]"]
)
+(ps["Positive electrode porosity"] * electrolyte_density)
negative_mass_density = (
ps["Negative electrode active material volume fraction"]
* ps["Negative electrode density [kg.m-3]"]
)
+(ps["Negative electrode porosity"] * electrolyte_density)

# Areal mass densities [kg.m-2]
positive_mass = ps["Positive electrode thickness [m]"] * positive_mass_density
NicolaCourtier marked this conversation as resolved.
Show resolved Hide resolved
negative_mass = ps["Negative electrode thickness [m]"] * negative_mass_density
separator_mass = (
ps["Separator thickness [m]"]
* ps["Separator porosity"]
* ps["Separator density [kg.m-3]"]
)
positive_current_collector_mass = (
ps["Positive current collector thickness [m]"]
* ps["Positive current collector density [kg.m-3]"]
)
negative_current_collector_mass = (
ps["Negative current collector thickness [m]"]
* ps["Negative current collector density [kg.m-3]"]
)

# Cross-sectional area [m2]
cross_sectional_area = ps["Electrode height [m]"] * ps["Electrode width [m]"]

return cross_sectional_area * (
positive_mass
+ separator_mass
+ negative_mass
+ positive_current_collector_mass
+ negative_current_collector_mass
)


# Define test protocol
experiment = pybamm.Experiment(
["Discharge at 1C until 2.5 V (5 seconds period)"],
)
init_soc = 1 # start from full charge
signal = ["Voltage [V]", "Current [A]"]

# Generate problem
problem = pybop.DesignProblem(
model, parameters, experiment, signal=signal, init_soc=init_soc
)

# Update the C-rate and the example dataset
nominal_capacity(problem.x0, model)
sol = problem.evaluate(problem.x0)
problem._time_data = sol[:, -1]
BradyPlanden marked this conversation as resolved.
Show resolved Hide resolved
problem._target = sol[:, 0:-1]


# Define cost function as a subclass
class GravimetricEnergyDensity(pybop.BaseCost):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be within _costs.py?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it should be taken out of context at this stage.

"""
Defines the (negative*) gravimetric energy density corresponding to a
normalised 1C discharge from upper to lower voltage limits.
*The energy density is maximised by minimising the negative energy density.
"""

def __init__(self, problem):
super().__init__(problem)

def _evaluate(self, x, grad=None):
"""
Compute the cost
"""
with warnings.catch_warnings(record=True) as w:
# Update the C-rate and run the simulation
nominal_capacity(x, self.problem._model)
sol = self.problem.evaluate(x)

if any(w) and issubclass(w[-1].category, UserWarning):
# Catch infeasible parameter combinations e.g. E_Li > Q_p
return np.inf

Check warning on line 146 in examples/scripts/spme_max_energy.py

View check run for this annotation

Codecov / codecov/patch

examples/scripts/spme_max_energy.py#L146

Added line #L146 was not covered by tests

else:
voltage = sol[:, 0]
current = sol[:, 1]
gravimetric_energy_density_Ah = np.trapz(voltage * current) / (
3600 * cell_mass(self.problem._model._parameter_set)
)
# Take negative in order to maximise energy density
NicolaCourtier marked this conversation as resolved.
Show resolved Hide resolved
return -gravimetric_energy_density_Ah


# Generate cost function and optimisation class
cost = GravimetricEnergyDensity(problem)
optim = pybop.Optimisation(cost, optimiser=pybop.PSO, verbose=True)
NicolaCourtier marked this conversation as resolved.
Show resolved Hide resolved
optim.set_max_iterations(15)

# Run optimisation
x, final_cost = optim.run()
print("Estimated parameters:", x)
print(f"Initial gravimetric energy density: {-cost(cost.x0):.2f} Wh.kg-1")
print(f"Optimised gravimetric energy density: {-final_cost:.2f} Wh.kg-1")

# Plot the timeseries output
nominal_capacity(x, cost.problem._model)
pybop.quick_plot(x, cost, title="Optimised Comparison")

# Plot the cost landscape with optimisation path
if len(x) == 2:
pybop.plot_cost2d(cost, optim=optim, steps=3)
Loading
Loading