Source code for pybamm.simulation.eis_simulation

from __future__ import annotations

import numpy as np
from scipy.sparse import csc_matrix
from scipy.sparse.linalg import spsolve

import pybamm

from .base_simulation import BaseSimulation


[docs] class EISSimulation(BaseSimulation): """Frequency-domain EIS simulation built on :class:`BaseSimulation`. Parameters ---------- model : :class:`pybamm.BaseModel` The model to be simulated. parameter_values : :class:`pybamm.ParameterValues`, optional Parameters and their corresponding numerical values. geometry : :class:`pybamm.Geometry`, optional The geometry upon which to solve the model. submesh_types : dict, optional A dictionary of the types of submesh to use on each subdomain. var_pts : dict, optional A dictionary of the number of points used by each spatial variable. spatial_methods : dict, optional A dictionary of the types of spatial method to use on each domain. skip_surface_form_check : bool, optional If True, skip the 'surface form' model option validation. Defaults to False. """ def __init__( self, model, parameter_values=None, geometry=None, submesh_types=None, var_pts=None, spatial_methods=None, skip_surface_form_check=False, ): timer = pybamm.Timer() # Validate required variables and surface form before any processing self._validate_model_for_eis(model, skip_surface_form_check) model_name = model.name parameter_values = parameter_values or model.default_parameter_values parameter_values = parameter_values.copy() pybamm.logger.info(f"Setting up {model_name} for EIS") # set current to zero as an algebraic state step = pybamm.step.CustomStepImplicit(lambda v: v["Current [A]"] - 0) model, parameter_values = step.set_up(model.new_copy(), parameter_values) model.initial_conditions[model.variables["Current [A]"]] = pybamm.Scalar(0) super().__init__( model, parameter_values=parameter_values, geometry=geometry, submesh_types=submesh_types, var_pts=var_pts, spatial_methods=spatial_methods, ) self.set_up_time = timer.time() pybamm.logger.info( f"Finished setting up {model_name} for EIS " f"(set-up time: {self.set_up_time})" ) pybamm.citations.register("Hallemans2025") @staticmethod def _validate_model_for_eis(model, skip_surface_form_check=False): """Validate that a model is suitable for frequency-domain EIS. Raises ------ ValueError If the model is missing required variables or options. """ required_vars = ["Voltage [V]", "Current [A]"] for var in required_vars: if var not in model.variables: raise ValueError( f"Model must contain variable '{var}' for EIS simulation" ) if skip_surface_form_check: return surface_form = model.options.get("surface form", "false") if surface_form not in ("differential", "algebraic"): raise ValueError( f"EIS simulation requires 'surface form' model option to be " f"'differential' or 'algebraic', got '{surface_form}'. " f"Use e.g. pybamm.lithium_ion.SPM(" f'options={{"surface form": "differential"}})' ) def _build_matrix_problem(self, inputs_dict=None): """Build the mass matrix, Jacobian, and forcing vector. The mass matrix ``M`` and forcing vector ``b`` are cached after the first call because they do not depend on the operating point. Only the Jacobian is re-evaluated when initial conditions change (e.g. different SOC). Parameters ---------- inputs_dict : dict, optional Input parameters to pass to the model. Returns ------- M : scipy.sparse.csc_matrix Mass matrix in CSC format. neg_J : scipy.sparse.csc_matrix Negated Jacobian in CSC format (pre-negated for the solve loop). b : np.ndarray Forcing vector with unit perturbation on the current variable. """ model = self._built_model inputs_dict = inputs_dict or {} # Convert inputs to casadi format for Jacobian evaluation if model.convert_to_format == "casadi": from casadi import vertcat casadi_inputs = vertcat(*inputs_dict.values()) if inputs_dict else [] else: casadi_inputs = inputs_dict # Only compile Jacobian/model functions on first call; the compiled # functions persist on the model and work with any inputs/y0 values. if getattr(model, "jac_rhs_algebraic_eval", None) is None: solver = pybamm.BaseSolver() solver.set_up(model, inputs=inputs_dict) y0 = model.concatenated_initial_conditions.evaluate(0, inputs=inputs_dict) outputs = pybamm.numpy_concatenation( model.get_processed_variable("Voltage [V]"), model.get_processed_variable("Current [A]"), ) from casadi import MX, Function, jacobian y = MX.sym("y", y0.size) output_expression = outputs.to_casadi(t=0, y=y, inputs=inputs_dict) self._output_jacobian = Function( "eis_outputs", [y], [jacobian(output_expression, y)] )(y0).sparse() J_sparse = model.jac_rhs_algebraic_eval(0, y0, casadi_inputs).sparse() neg_J = -csc_matrix(J_sparse) # State ordering and matrices are fixed until the model is rebuilt. if getattr(self, "_matrix_model", None) is not model: indices = { var.name: slices[0].start for var, slices in model.y_slices.items() } self._current_index = indices["Current variable [A]"] self._cached_M = csc_matrix(model.mass_matrix.entries) self._cached_b = np.zeros(y0.shape[0]) self._cached_b[self._current_index] = -1 self._matrix_model = model return self._cached_M, neg_J, self._cached_b def _calculate_impedance(self, frequency, M, neg_J, b): """Calculate impedance at a single frequency. Parameters ---------- frequency : float Frequency in Hz. M : scipy.sparse.csc_matrix Mass matrix. neg_J : scipy.sparse.csc_matrix Negated Jacobian. b : np.ndarray Forcing vector. Returns ------- z : complex Complex impedance in Ohms. """ A = (1.0j * 2 * np.pi * frequency) * M + neg_J x = spsolve(A, b) voltage, current = self._output_jacobian @ x return -voltage / current
[docs] def solve(self, frequencies, inputs=None, initial_soc=None): """Compute impedance at the given frequencies. Solves the linear system ``(i*omega*M - J) x = b`` at each frequency using a direct sparse solver. Parameters ---------- frequencies : array-like Frequencies in Hz at which to compute impedance. inputs : dict, optional Input parameters to pass to the model. initial_soc : float or str, optional Initial State of Charge. If given, the model is rebuilt with the new SOC before solving. Returns ------- :class:`pybamm.EISSolution` Solution containing frequencies and complex impedance values. """ model_name = self._model.name pybamm.logger.info(f"Start calculating impedance for {model_name}") timer = pybamm.Timer() self.build(initial_soc=initial_soc, inputs=inputs) M, neg_J, b = self._build_matrix_problem(inputs_dict=inputs) zs = [self._calculate_impedance(f, M, neg_J, b) for f in frequencies] impedance = np.array(zs) self._solution = pybamm.EISSolution(frequencies, impedance) self._solution.set_up_time = self.set_up_time self.solve_time = timer.time() self._solution.solve_time = self.solve_time pybamm.logger.info( f"Finished calculating impedance for {model_name} " f"(solve time: {self.solve_time})" ) return self._solution
[docs] def nyquist_plot(self, **kwargs): """Generate a Nyquist plot from the most recent solution. Parameters ---------- **kwargs Keyword arguments forwarded to :func:`pybamm.nyquist_plot`. Returns ------- fig : matplotlib.figure.Figure or None ax : matplotlib.axes.Axes """ if self._solution is None: raise ValueError( "EIS simulation has not been solved yet. Call solve() before plotting." ) return self._solution.nyquist_plot(**kwargs)