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

Summary variables calculated only when called #4621

Open
wants to merge 13 commits into
base: develop
Choose a base branch
from
1 change: 1 addition & 0 deletions docs/source/api/solvers/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ Solvers
algebraic_solvers
solution
processed_variable
summary_variables
5 changes: 5 additions & 0 deletions docs/source/api/solvers/summary_variables.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Summary Variables
======================

.. autoclass:: pybamm.SummaryVariables
:members:
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,7 @@
},
{
"cell_type": "code",
"execution_count": 7,
"execution_count": null,
"id": "right-skiing",
"metadata": {},
"outputs": [
Expand Down Expand Up @@ -638,7 +638,7 @@
}
],
"source": [
"sorted(sol.summary_variables.keys())"
"sorted(sol.summary_variables.all_variables)"
]
},
{
Expand Down Expand Up @@ -1936,7 +1936,7 @@
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"display_name": "venv",
"language": "python",
"name": "python3"
},
Expand All @@ -1950,7 +1950,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.10"
"version": "3.11.10"
},
"toc": {
"base_numbering": 1,
Expand All @@ -1964,11 +1964,6 @@
"toc_position": {},
"toc_section_display": true,
"toc_window_display": true
},
"vscode": {
"interpreter": {
"hash": "612adcc456652826e82b485a1edaef831aa6d5abc680d008e93d513dd8724f14"
}
}
},
"nbformat": 4,
Expand Down
1 change: 1 addition & 0 deletions src/pybamm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@
from .solvers.processed_variable_time_integral import ProcessedVariableTimeIntegral
from .solvers.processed_variable import ProcessedVariable, process_variable
from .solvers.processed_variable_computed import ProcessedVariableComputed
from .solvers.summary_variable import SummaryVariables
from .solvers.base_solver import BaseSolver
from .solvers.dummy_solver import DummySolver
from .solvers.algebraic_solver import AlgebraicSolver
Expand Down
2 changes: 1 addition & 1 deletion src/pybamm/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ def on_cycle_end(self, logs):

voltage_stop = logs["stopping conditions"]["voltage"]
if voltage_stop is not None:
min_voltage = logs["summary variables"]["Minimum voltage [V]"]
min_voltage = logs["Minimum voltage [V]"]
if min_voltage > voltage_stop[0]:
self.logger.notice(
f"Minimum voltage is now {min_voltage:.3f} V "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,27 @@ def __init__(
self.__get_electrode_soh_sims_split
)

def __getstate__(self):
"""
Return dictionary of picklable items
"""
result = self.__dict__.copy()
result["_get_electrode_soh_sims_full"] = None # Exclude LRU cache
result["_get_electrode_soh_sims_split"] = None # Exclude LRU cache
return result

def __setstate__(self, state):
"""
Unpickle, restoring unpicklable relationships
"""
self.__dict__ = state
self._get_electrode_soh_sims_full = lru_cache()(
self.__get_electrode_soh_sims_full
)
self._get_electrode_soh_sims_split = lru_cache()(
self.__get_electrode_soh_sims_split
)

def _get_lims_ocp(self):
parameter_values = self.parameter_values

Expand Down
4 changes: 2 additions & 2 deletions src/pybamm/plotting/plot_summary_variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,13 @@ def plot_summary_variables(
for solution in solutions:
# plot summary variable v/s cycle number
ax.plot(
solution.summary_variables["Cycle number"],
solution.summary_variables.cycle_number,
solution.summary_variables[var],
)
# label the axes
ax.set_xlabel("Cycle number")
ax.set_ylabel(var)
ax.set_xlim([1, solution.summary_variables["Cycle number"][-1]])
ax.set_xlim([1, solution.summary_variables.cycle_number[-1]])

fig.tight_layout()

Expand Down
4 changes: 2 additions & 2 deletions src/pybamm/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -920,7 +920,7 @@ def solve(
# See PR #3995
if voltage_stop is not None:
min_voltage = np.min(cycle_solution["Battery voltage [V]"].data)
logs["summary variables"]["Minimum voltage [V]"] = min_voltage
logs["Minimum voltage [V]"] = min_voltage

callbacks.on_cycle_end(logs)

Expand All @@ -941,7 +941,7 @@ def solve(

if self._solution is not None and len(all_cycle_solutions) > 0:
self._solution.cycles = all_cycle_solutions
self._solution.set_summary_variables(all_summary_variables)
self._solution.update_summary_variables(all_summary_variables)
self._solution.all_first_states = all_first_states

callbacks.on_experiment_end(logs)
Expand Down
59 changes: 5 additions & 54 deletions src/pybamm/solvers/solution.py
Original file line number Diff line number Diff line change
Expand Up @@ -563,16 +563,10 @@ def initial_start_time(self, value):
"""Updates the initial start time of the experiment"""
self._initial_start_time = value

def set_summary_variables(self, all_summary_variables):
summary_variables = {var: [] for var in all_summary_variables[0]}
for sum_vars in all_summary_variables:
for name, value in sum_vars.items():
summary_variables[name].append(value)

summary_variables["Cycle number"] = range(1, len(all_summary_variables) + 1)
def update_summary_variables(self, all_summary_variables):
self.all_summary_variables = all_summary_variables
self._summary_variables = pybamm.FuzzyDict(
{name: np.array(value) for name, value in summary_variables.items()}
self._summary_variables = pybamm.SummaryVariables(
self, cycle_summary_variables=all_summary_variables
)

def update(self, variables):
Expand Down Expand Up @@ -1142,8 +1136,8 @@ def make_cycle_solution(

cycle_solution.steps = step_solutions

cycle_summary_variables = _get_cycle_summary_variables(
cycle_solution, esoh_solver, user_inputs=inputs
cycle_summary_variables = pybamm.SummaryVariables(
cycle_solution, esoh_solver=esoh_solver, user_inputs=inputs
)

cycle_first_state = cycle_solution.first_state
Expand All @@ -1154,46 +1148,3 @@ def make_cycle_solution(
cycle_solution = None

return cycle_solution, cycle_summary_variables, cycle_first_state


def _get_cycle_summary_variables(cycle_solution, esoh_solver, user_inputs=None):
user_inputs = user_inputs or {}
model = cycle_solution.all_models[0]
cycle_summary_variables = pybamm.FuzzyDict({})

# Summary variables
summary_variables = model.summary_variables
first_state = cycle_solution.first_state
last_state = cycle_solution.last_state
for var in summary_variables:
data_first = first_state[var].data
data_last = last_state[var].data
cycle_summary_variables[var] = data_last[0]
var_lowercase = var[0].lower() + var[1:]
cycle_summary_variables["Change in " + var_lowercase] = (
data_last[0] - data_first[0]
)

# eSOH variables (full-cell lithium-ion model only, for now)
if (
esoh_solver is not None
and isinstance(model, pybamm.lithium_ion.BaseModel)
and model.options.electrode_types["negative"] == "porous"
and "Negative electrode capacity [A.h]" in model.variables
and "Positive electrode capacity [A.h]" in model.variables
):
Q_n = last_state["Negative electrode capacity [A.h]"].data[0]
Q_p = last_state["Positive electrode capacity [A.h]"].data[0]
Q_Li = last_state["Total lithium capacity in particles [A.h]"].data[0]
all_inputs = {**user_inputs, "Q_n": Q_n, "Q_p": Q_p, "Q_Li": Q_Li}
try:
esoh_sol = esoh_solver.solve(inputs=all_inputs)
except pybamm.SolverError as error: # pragma: no cover
raise pybamm.SolverError(
"Could not solve for summary variables, run "
"`sim.solve(calc_esoh=False)` to skip this step"
) from error

cycle_summary_variables.update(esoh_sol)

return cycle_summary_variables
182 changes: 182 additions & 0 deletions src/pybamm/solvers/summary_variable.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#
# Summary Variable class
#
from __future__ import annotations
import pybamm
import numpy as np
from typing import Any


class SummaryVariables:
"""
Class for managing and calculating summary variables from a PyBaMM solution.
Summary variables are only calculated when simulations are run with PyBaMM
Experiments.

Parameters
----------
solution : :class:`pybamm.Solution`
The solution object to be used for creating the processed variables.
cycle_summary_variables : list[pybamm.SummaryVariables], optional
A list of cycle summary variables.
esoh_solver : :class:`pybamm.lithium_ion.ElectrodeSOHSolver`, optional
Solver for electrode state-of-health (eSOH) calculations.
user_inputs : dict, optional
Additional user inputs for calculations.
"""

def __init__(
self,
solution: pybamm.Solution,
cycle_summary_variables: list[SummaryVariables] | None = None,
esoh_solver: pybamm.lithium_ion.ElectrodeSOHSolver | None = None,
user_inputs: dict[str, Any] | None = None,
):
self.user_inputs = user_inputs or {}
self.esoh_solver = esoh_solver
self._variables = {} # Store computed variables
self.cycle_number = None

model = solution.all_models[0]
self._possible_variables = model.summary_variables # minus esoh variables
self._esoh_variables = None # Store eSOH variable names

# Flag if eSOH calculations are needed
Copy link
Contributor

Choose a reason for hiding this comment

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

related: #4619

self.calc_esoh = (
self.esoh_solver is not None
and isinstance(model, pybamm.lithium_ion.BaseModel)
and model.options.electrode_types["negative"] == "porous"
and "Negative electrode capacity [A.h]" in model.variables
and "Positive electrode capacity [A.h]" in model.variables
)

# Initialize based on cycle information
if cycle_summary_variables:
self._initialize_for_cycles(cycle_summary_variables)
else:
self.first_state = solution.first_state
self.last_state = solution.last_state
self.cycles = None

def _initialize_for_cycles(self, cycle_summary_variables: list[SummaryVariables]):
"""Initialize attributes for when multiple cycles are provided."""
self.first_state = None
self.last_state = None
self.cycles = cycle_summary_variables
self.cycle_number = np.arange(1, len(self.cycles) + 1)
first_cycle = self.cycles[0]
self.calc_esoh = first_cycle.calc_esoh
self.esoh_solver = first_cycle.esoh_solver
self.user_inputs = first_cycle.user_inputs

@property
def all_variables(self) -> list[str]:
"""
Return names of all possible summary variables, including eSOH variables
if appropriate.
"""
try:
return self._all_variables
except AttributeError:
base_vars = self._possible_variables.copy()
base_vars.extend(
f"Change in {var[0].lower() + var[1:]}"
for var in self._possible_variables
)

if self.calc_esoh:
base_vars.extend(self.esoh_variables)

self._all_variables = base_vars
return self._all_variables

@property
def esoh_variables(self) -> list[str] | None:
"""Return names of all eSOH variables."""
if self.calc_esoh and self._esoh_variables is None:
esoh_model = self.esoh_solver._get_electrode_soh_sims_full().model
esoh_vars = list(esoh_model.variables.keys())
self._esoh_variables = esoh_vars
return self._esoh_variables

def __getitem__(self, key: str) -> float | list[float]:
"""
Access or compute a summary variable by its name.

Parameters
----------
key : str
The name of the variable

Returns
-------
float or list[float]
"""

if key in self._variables:
# return it if it exists
return self._variables[key]
elif key not in self.all_variables:
# check it's listed as a summary variable
raise KeyError(f"Variable '{key}' is not a summary variable.")
else:
# otherwise create it, save it and then return it
if self.calc_esoh and key in self._esoh_variables:
self.update_esoh()
else:
base_key = key.removeprefix("Change in ")
base_key = base_key[0].upper() + base_key[1:]
# this will create 'X' and 'Change in x' at the same time
self.update(base_key)
return self._variables[key]

def update(self, var: str):
"""Compute and store a variable and its change."""
var_lowercase = var[0].lower() + var[1:]
if self.cycles:
self._update_multiple_cycles(var, var_lowercase)
else:
self._update(var, var_lowercase)

def _update_multiple_cycles(self, var: str, var_lowercase: str):
"""Creates aggregated summary variables for where more than one cycle exists."""
var_cycle = [cycle[var] for cycle in self.cycles]
change_var_cycle = [
cycle[f"Change in {var_lowercase}"] for cycle in self.cycles
]
self._variables[var] = var_cycle
self._variables[f"Change in {var_lowercase}"] = change_var_cycle

def _update(self, var: str, var_lowercase: str):
"""Create variable `var` for a single cycle."""
data_first = self.first_state[var].data
data_last = self.last_state[var].data
self._variables[var] = data_last[0]
self._variables[f"Change in {var_lowercase}"] = data_last[0] - data_first[0]

def update_esoh(self):
"""Create all aggregated eSOH variables"""
if self.cycles is not None:
var_cycle = [cycle._get_esoh_variables() for cycle in self.cycles]
aggregated_vars = {k: [] for k in var_cycle[0].keys()}
for cycle in var_cycle:
for k, v in cycle.items():
aggregated_vars[k].append(v)
self._variables.update(aggregated_vars)
else:
self._variables.update(self._get_esoh_variables())

def _get_esoh_variables(self) -> dict[str, float]:
"""Compute eSOH variables for a single solution."""
Q_n = self.last_state["Negative electrode capacity [A.h]"].data[0]
Q_p = self.last_state["Positive electrode capacity [A.h]"].data[0]
Q_Li = self.last_state["Total lithium capacity in particles [A.h]"].data[0]
all_inputs = {**self.user_inputs, "Q_n": Q_n, "Q_p": Q_p, "Q_Li": Q_Li}
try:
esoh_sol = self.esoh_solver.solve(inputs=all_inputs)
except pybamm.SolverError as error: # pragma: no cover
raise pybamm.SolverError(
"Could not solve for eSOH summary variables"
) from error

return esoh_sol
Loading
Loading