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

Design Optimisation Refactor and Additions #167

Merged
merged 16 commits into from
Feb 9, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
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
14,702 changes: 14,702 additions & 0 deletions examples/notebooks/spm_electrode_design.ipynb

Large diffs are not rendered by default.

138 changes: 3 additions & 135 deletions examples/scripts/spme_max_energy.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import pybop
import numpy as np
import warnings

## NOTE: This is a brittle example, the classes and methods below will be
## integrated into pybop in a future release.
Expand Down Expand Up @@ -31,90 +29,6 @@
bounds=[2e-06, 9e-06],
),
]
# Define stoichiometries
sto = model._electrode_soh.get_min_max_stoichiometries(parameter_set)
mean_sto_neg = np.mean(sto[0:2])
mean_sto_pos = np.mean(sto[2:4])


# Define functions
def nominal_capacity(
x, model
): # > 50% of the simulation time is spent in this function (~0.7 sec per iteration vs ~1.1 for the forward simulation)
"""
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( # All of the computational time is in this line (~0.7)
model._parameter_set
)
average_voltage = model._parameter_set["Positive electrode OCP [V]"](
mean_sto_pos
) - model._parameter_set["Negative electrode OCP [V]"](mean_sto_neg)

theoretical_capacity = theoretical_energy / average_voltage
model._parameter_set.update({"Nominal cell capacity [A.h]": theoretical_capacity})


def cell_mass(parameter_set): # This is very low compute time
"""
Compute the total cell mass [kg] for the current parameter set.
"""

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

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

# Area densities [kg.m-2]
positive_area_density = (
parameter_set["Positive electrode thickness [m]"] * positive_mass_density
)
negative_area_density = (
parameter_set["Negative electrode thickness [m]"] * negative_mass_density
)
separator_area_density = (
parameter_set["Separator thickness [m]"]
* parameter_set["Separator porosity"]
* parameter_set["Separator density [kg.m-3]"]
)
positive_current_collector_area_density = (
parameter_set["Positive current collector thickness [m]"]
* parameter_set["Positive current collector density [kg.m-3]"]
)
negative_current_collector_area_density = (
parameter_set["Negative current collector thickness [m]"]
* parameter_set["Negative current collector density [kg.m-3]"]
)

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

return cross_sectional_area * (
positive_area_density
+ separator_area_density
+ negative_area_density
+ positive_current_collector_area_density
+ negative_current_collector_area_density
)


# Define test protocol
experiment = pybop.Experiment(
Expand All @@ -128,58 +42,12 @@ def cell_mass(parameter_set): # This is very low compute time
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]
problem._target = sol[:, 0:-1]
dt = sol[1, -1] - sol[0, -1]


# Define cost function as a subclass
class GravimetricEnergyDensity(pybop.BaseCost):
"""
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
print(f"ignoring this sample due to: {w[-1].message}")
return np.inf

else:
voltage = sol[:, 0]
current = sol[:, 1]
gravimetric_energy_density = -np.trapz(
voltage * current, dx=dt
) / ( # trapz over-estimates compares to pybamm (~0.5%)
3600 * cell_mass(self.problem._model._parameter_set)
)
# Return the negative energy density, as the optimiser minimises
# this function, to carry out maximisation of the energy density
return gravimetric_energy_density


# Generate cost function and optimisation class
cost = GravimetricEnergyDensity(problem)
cost = pybop.GravimetricEnergyDensity(problem)
optim = pybop.Optimisation(
cost, optimiser=pybop.PSO, verbose=True, allow_infeasible_solutions=False
)
optim.set_max_iterations(5)
optim.set_max_iterations(15)

# Run optimisation
x, final_cost = optim.run()
Expand All @@ -188,7 +56,7 @@ def _evaluate(self, x, grad=None):
print(f"Optimised gravimetric energy density: {-final_cost:.2f} Wh.kg-1")

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

# Plot the cost landscape with optimisation path
Expand Down
8 changes: 7 additions & 1 deletion pybop/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@
#
# Cost function class
#
from ._costs import BaseCost, RootMeanSquaredError, SumSquaredError, ObserverCost
from ._costs import (
BaseCost,
RootMeanSquaredError,
SumSquaredError,
ObserverCost,
GravimetricEnergyDensity,
)

#
# Dataset class
Expand Down
86 changes: 86 additions & 0 deletions pybop/_costs.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import numpy as np
import warnings

from pybop.observers.observer import Observer

Expand Down Expand Up @@ -288,6 +289,91 @@
self._de = de


class GravimetricEnergyDensity(BaseCost):
"""
Represents the gravimetric energy density of a battery cell, calculated based
on a normalized discharge from upper to lower voltage limits. The goal is to
maximize the energy density, which is achieved by minimizing the negative energy
density reported by this class.

Attributes:
problem (object): The associated problem containing model and evaluation methods.
parameter_set (object): The set of parameters from the problem's model.
dt (float): The time step size used in the simulation.
"""

def __init__(self, problem, update_capacity=False):
"""
Initializes the gravimetric energy density calculator with a problem.

Args:
problem (object): The problem instance containing the model and data.
"""
super().__init__(problem)
self.problem = problem
if update_capacity is True:
nominal_capacity_warning = (
"The nominal capacity is approximated for each iteration."
)
else:
nominal_capacity_warning = (
"The nominal capacity is fixed at the initial model value."
)
warnings.warn(nominal_capacity_warning, UserWarning)
self.update_capacity = update_capacity
self.parameter_set = problem._model._parameter_set
self.update_simulation_data(problem.x0)

def update_simulation_data(self, initial_conditions):
"""
Updates the simulation data based on the initial conditions.

Args:
initial_conditions (array): The initial conditions for the simulation.
"""
if self.update_capacity:
self.problem._model.approximate_capacity(self.problem.x0)
solution = self.problem.evaluate(initial_conditions)
self.problem._time_data = solution[:, -1]
self.problem._target = solution[:, 0:-1]
self.dt = solution[1, -1] - solution[0, -1]

def _evaluate(self, x, grad=None):
"""
Computes the cost function for the energy density.

Args:
x (array): The parameter set for which to compute the cost.
grad (array, optional): Gradient information, not used in this method.

Returns:
float: The negative gravimetric energy density or infinity in case of infeasible parameters.
"""
try:
with warnings.catch_warnings():
# Convert UserWarning to an exception
warnings.filterwarnings("error", category=UserWarning)

if self.update_capacity:
self.problem._model.approximate_capacity(x)
solution = self.problem.evaluate(x)

voltage, current = solution[:, 0], solution[:, 1]
negative_energy_density = -np.trapz(voltage * current, dx=self.dt) / (
3600 * self.problem._model.cell_mass(self.parameter_set)
)

return negative_energy_density

except UserWarning as e:
print(f"Ignoring this sample due to: {e}")
return np.inf

except Exception as e:
print(f"An error occurred during the evaluation: {e}")
return np.inf

Check warning on line 374 in pybop/_costs.py

View check run for this annotation

Codecov / codecov/patch

pybop/_costs.py#L372-L374

Added lines #L372 - L374 were not covered by tests


class ObserverCost(BaseCost):
"""
Observer cost function.
Expand Down
38 changes: 38 additions & 0 deletions pybop/models/base_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,44 @@
"""
return True

def cell_mass(self, parameter_set=None):
"""
Calculate the cell mass in kilograms.

This method must be implemented by subclasses.

Parameters
----------
parameter_set : dict, optional
A dictionary containing the parameter values necessary for the mass
calculations.

Raises
------
NotImplementedError
If the method has not been implemented by the subclass.
"""
raise NotImplementedError

Check warning on line 461 in pybop/models/base_model.py

View check run for this annotation

Codecov / codecov/patch

pybop/models/base_model.py#L461

Added line #L461 was not covered by tests

def approximate_capacity(self, x):
"""
Calculate a new estimate for the nominal capacity based on the theoretical energy density
and an average voltage.

This method must be implemented by subclasses.

Parameters
----------
x : array-like
An array of values representing the model inputs.

Raises
------
NotImplementedError
If the method has not been implemented by the subclass.
"""
raise NotImplementedError

Check warning on line 480 in pybop/models/base_model.py

View check run for this annotation

Codecov / codecov/patch

pybop/models/base_model.py#L480

Added line #L480 was not covered by tests

@property
def built_model(self):
return self._built_model
Expand Down
4 changes: 2 additions & 2 deletions pybop/models/empirical/ecm.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import pybamm
from ..base_model import BaseModel
from .ecm_base import ECircuitModel


class Thevenin(BaseModel):
class Thevenin(ECircuitModel):
"""
The Thevenin class represents an equivalent circuit model based on the Thevenin model in PyBaMM.

Expand Down
29 changes: 29 additions & 0 deletions pybop/models/empirical/ecm_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from ..base_model import BaseModel


class ECircuitModel(BaseModel):
"""
Overwrites and extends `BaseModel` class for circuit-based PyBaMM models.
"""

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

def _check_params(self, inputs=None, allow_infeasible_solutions=True):
"""
Check the compatibility of the model parameters.

Parameters
----------
inputs : dict
The input parameters for the simulation.
allow_infeasible_solutions : bool, optional
If True, infeasible parameter values will be allowed in the optimisation (default: True).

Returns
-------
bool
A boolean which signifies whether the parameters are compatible.

"""
return True

Check warning on line 29 in pybop/models/empirical/ecm_base.py

View check run for this annotation

Codecov / codecov/patch

pybop/models/empirical/ecm_base.py#L29

Added line #L29 was not covered by tests
Loading
Loading