├── .gitignore ├── LICENSE.md ├── README.md ├── examples ├── compare_methods.py ├── compare_models.py └── run_simulation.py ├── pbeis ├── __init__.py ├── eis_simulation.py ├── numerical_methods.py ├── plotting.py └── utils.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | *.egg-info 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | pip-wheel-metadata/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 97 | __pypackages__/ 98 | 99 | # Celery stuff 100 | celerybeat-schedule 101 | celerybeat.pid 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | .env 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | 133 | # Data 134 | data/ 135 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2022 University of Oxford 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!IMPORTANT] 2 | > This repository is now maintained under the pybamm-team umbrella, and can be found [here](https://github.com/pybamm-team/pybamm-eis). 3 | 4 | # PyBaMM EIS 5 | PyBaMM EIS rapidly calculates the electrochemical impedance of any battery model defined using PyBaMM. 6 | 7 | 8 | This code was developed as part of the Oxford Mathematics Summer Project _"Efficient Linear Algebra Methods to Determine Li-ion Battery Behaviour"_. 9 | 10 | Student: Rishit Dhoot 11 | 12 | Supervisors: Prof Colin Please and Dr. Robert Timms 13 | 14 | ## 🔋 Using PyBaMM EIS 15 | The easiest way to use PyBaMM EIS is to compute the impedance of a model of your choice with the default parameters: 16 | ```python3 17 | import pbeis 18 | import pybamm 19 | 20 | model = pybamm.lithium_ion.DFN(options={"surface form": "differential"}) # DFN with capacitance 21 | eis_sim = pbeis.EISSimulation(model) 22 | eis_sim.solve(pbeis.logspace(-4, 4, 30)) # calculate impedance at log-spaced frequencieshttps://github.com/pybamm-team/pybamm-eis 23 | eis_sim.nyquist_plot() 24 | ``` 25 | 26 | ## 💻 About PyBaMM 27 | The example simulations use the package [PyBaMM](www.pybamm.org) (Python Battery Mathematical Modelling). PyBaMM is an open-source battery simulation package 28 | written in Python. Our mission is to accelerate battery modelling research by 29 | providing open-source tools for multi-institutional, interdisciplinary collaboration. 30 | Broadly, PyBaMM consists of 31 | (i) a framework for writing and solving systems 32 | of differential equations, 33 | (ii) a library of battery models and parameters, and 34 | (iii) specialized tools for simulating battery-specific experiments and visualizing the results. 35 | Together, these enable flexible model definitions and fast battery simulations, allowing users to 36 | explore the effect of different battery designs and modeling assumptions under a variety of operating scenarios. 37 | 38 | ## 🚀 Installation 39 | In order to run the notebooks in this repository you will need to install the `pybamm-eis` package. We recommend installing within a [virtual environment](https://docs.python.org/3/tutorial/venv.html) in order to not alter any python distribution files on your machine. 40 | 41 | PyBaMM is available on GNU/Linux, MacOS and Windows. For more detailed instructions on how to install PyBaMM, see [the PyBaMM documentation](https://pybamm.readthedocs.io/en/latest/install/GNU-linux.html#user-install). 42 | 43 | ### Linux/Mac OS 44 | To install the requirements on Linux/Mac OS use the following terminal commands: 45 | 46 | 1. Clone the repository 47 | ```bash 48 | git clone https://github.com/rish31415/pybamm-eis 49 | ``` 50 | 2. Change into the `pybamm-eis` directory 51 | ```bash 52 | cd pybamm-eis 53 | ``` 54 | 3. Create a virtual environment 55 | ```bash 56 | virtualenv env 57 | ``` 58 | 4. Activate the virtual environment 59 | ```bash 60 | source env/bin/activate 61 | ``` 62 | 5. Install the `pbeis` package 63 | ```bash 64 | pip install . 65 | ``` 66 | 67 | ### Windows 68 | To install the requirements on Windows use the following commands: 69 | 70 | 1. Clone the repository 71 | ```bash 72 | git clone https://github.com/rish31415/pybamm-eis 73 | ``` 74 | 2. Change into the `pybamm-eis` directory 75 | ```bash 76 | cd pybamm-eis 77 | ``` 78 | 3. Create a virtual environment 79 | ```bash 80 | python -m virtualenv env 81 | ``` 82 | 4. Activate the virtual environment 83 | ```bash 84 | \path\to\env\Scripts\activate 85 | ``` 86 | where `\path\to\env` is the path to the environment created in step 3 (e.g. `C:\Users\'Username'\env\Scripts\activate.bat`). 87 | 88 | 5. Install the `pbeis` package 89 | ```bash 90 | pip install . 91 | ``` 92 | 93 | As an alternative, you can set up [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/about). This allows you to run a full Linux distribution within Windows. 94 | 95 | ### Developer 96 | To install as a developer follow the instructions above, replacing the final step with 97 | ```bash 98 | pip install -e . 99 | ``` 100 | This will allow you to edit the code locally. 101 | 102 | ## 📫 Get in touch 103 | If you have any questions, or would like to know more about the project, please get in touch via email . 104 | -------------------------------------------------------------------------------- /examples/compare_methods.py: -------------------------------------------------------------------------------- 1 | import pbeis 2 | import pybamm 3 | import numpy as np 4 | import matplotlib.pyplot as plt 5 | import time as timer 6 | from scipy.fft import fft 7 | 8 | # Set up 9 | model = pybamm.lithium_ion.SPM(options={"surface form": "differential"}, name="SPM") 10 | parameter_values = pybamm.ParameterValues("Marquis2019") 11 | frequencies = np.logspace(-4, 2, 30) 12 | 13 | # Time domain 14 | I = 50 * 1e-3 15 | number_of_periods = 20 16 | samples_per_period = 16 17 | 18 | 19 | def current_function(t): 20 | return I * pybamm.sin(2 * np.pi * pybamm.InputParameter("Frequency [Hz]") * t) 21 | 22 | 23 | parameter_values["Current function [A]"] = current_function 24 | 25 | start_time = timer.time() 26 | 27 | sim = pybamm.Simulation( 28 | model, parameter_values=parameter_values, solver=pybamm.ScipySolver() 29 | ) 30 | 31 | impedances_time = [] 32 | for frequency in frequencies: 33 | # Solve 34 | period = 1 / frequency 35 | dt = period / samples_per_period 36 | t_eval = np.array(range(0, 1 + samples_per_period * number_of_periods)) * dt 37 | sol = sim.solve(t_eval, inputs={"Frequency [Hz]": frequency}) 38 | # Extract final two periods of the solution 39 | time = sol["Time [s]"].entries[-3 * samples_per_period - 1 :] 40 | current = sol["Current [A]"].entries[-3 * samples_per_period - 1 :] 41 | voltage = sol["Terminal voltage [V]"].entries[-3 * samples_per_period - 1 :] 42 | # FFT 43 | current_fft = fft(current) 44 | voltage_fft = fft(voltage) 45 | # Get index of first harmonic 46 | idx = np.argmax(np.abs(current_fft)) 47 | impedance = -voltage_fft[idx] / current_fft[idx] 48 | impedances_time.append(impedance) 49 | 50 | end_time = timer.time() 51 | time_elapsed = end_time - start_time 52 | print("Time domain method: ", time_elapsed, "s") 53 | 54 | # Frequency domain 55 | methods = ["direct", "prebicgstab"] 56 | impedances_freqs = [] 57 | for method in methods: 58 | start_time = timer.time() 59 | eis_sim = pbeis.EISSimulation(model, parameter_values=parameter_values) 60 | impedances_freq = eis_sim.solve(frequencies, method) 61 | end_time = timer.time() 62 | time_elapsed = end_time - start_time 63 | print(f"Frequency domain ({method}): ", time_elapsed, "s") 64 | impedances_freqs.append(impedances_freq) 65 | 66 | # Compare 67 | _, ax = plt.subplots() 68 | ax = pbeis.nyquist_plot(impedances_time, ax=ax, label="Time", alpha=0.7) 69 | for i, method in enumerate(methods): 70 | ax = pbeis.nyquist_plot( 71 | impedances_freqs[i], ax=ax, label=f"Frequency ({method})", alpha=0.7 72 | ) 73 | ax.legend() 74 | plt.suptitle(f"{model.name}") 75 | plt.savefig(f"figures/{model.name}_time_vs_freq.pdf", dpi=300) 76 | plt.show() 77 | -------------------------------------------------------------------------------- /examples/compare_models.py: -------------------------------------------------------------------------------- 1 | import pbeis 2 | import pybamm 3 | import matplotlib.pyplot as plt 4 | 5 | # Load models and parameters 6 | models = [ 7 | pybamm.lithium_ion.SPM(options={"surface form": "differential"}, name="SPM"), 8 | pybamm.lithium_ion.DFN(options={"surface form": "differential"}, name="DFN"), 9 | pybamm.lithium_ion.SPM( 10 | { 11 | "surface form": "differential", 12 | "current collector": "potential pair", 13 | "dimensionality": 2, 14 | }, 15 | name="SPM (pouch)", 16 | ), 17 | pybamm.lithium_ion.DFN( 18 | { 19 | "surface form": "differential", 20 | "current collector": "potential pair", 21 | "dimensionality": 2, 22 | }, 23 | name="DFN (pouch)", 24 | ), 25 | ] 26 | parameter_values = pybamm.ParameterValues("Marquis2019") 27 | parameter_values = pybamm.get_size_distribution_parameters( 28 | parameter_values, sd_n=0.2, sd_p=0.4 29 | ) 30 | 31 | # Loop over models and calculate impedance 32 | frequencies = pbeis.logspace(-4, 4, 30) 33 | impedances = [] 34 | for model in models: 35 | print(f"Start calculating impedance for {model.name}") 36 | eis_sim = pbeis.EISSimulation(model, parameter_values=parameter_values) 37 | impedances_freq = eis_sim.solve( 38 | frequencies, 39 | ) 40 | print(f"Finished calculating impedance for {model.name}") 41 | print( 42 | "Number of states: ", 43 | eis_sim.y0.shape[0], 44 | "Set-up time: ", 45 | eis_sim.set_up_time, 46 | "Solve time: ", 47 | eis_sim.solve_time, 48 | ) 49 | impedances.append(impedances_freq) 50 | 51 | # Compare 52 | _, ax = plt.subplots() 53 | for i, model in enumerate(models): 54 | ax = pbeis.nyquist_plot( 55 | impedances[i], ax=ax, linestyle="-", label=f"{model.name}", alpha=0.7 56 | ) 57 | ax.legend() 58 | plt.savefig("figures/compare_models.pdf", dpi=300) 59 | plt.show() 60 | -------------------------------------------------------------------------------- /examples/run_simulation.py: -------------------------------------------------------------------------------- 1 | import pbeis 2 | import pybamm 3 | 4 | # Load model (DFN with capacitance) 5 | model = pybamm.lithium_ion.DFN(options={"surface form": "differential"}) 6 | 7 | # Create simulation 8 | eis_sim = pbeis.EISSimulation(model) 9 | 10 | # Choose frequencies and calculate impedance 11 | frequencies = pbeis.logspace(-4, 4, 30) 12 | eis_sim.solve(frequencies) 13 | 14 | # Generate a Nyquist plot 15 | eis_sim.nyquist_plot() 16 | -------------------------------------------------------------------------------- /pbeis/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # PyBaMM EIS package 3 | # 4 | 5 | __version__ = "0.1.0" 6 | 7 | from .eis_simulation import EISSimulation 8 | from .numerical_methods import bicgstab, conjugate_gradient, prebicgstab 9 | from .plotting import nyquist_plot 10 | from .utils import logspace, SymbolReplacer 11 | -------------------------------------------------------------------------------- /pbeis/eis_simulation.py: -------------------------------------------------------------------------------- 1 | import pybamm 2 | import pbeis 3 | import numpy as np 4 | import time 5 | from scipy.sparse.linalg import splu 6 | from scipy.sparse import csc_matrix 7 | 8 | 9 | class EISSimulation: 10 | """ 11 | A Simulation class for easy building and running of PyBaMM EIS simulations 12 | using a frequency domain approach. 13 | 14 | Parameters 15 | ---------- 16 | model : :class:`pybamm.BaseModel` 17 | The model to be simulated 18 | parameter_values: :class:`pybamm.ParameterValues` (optional) 19 | Parameters and their corresponding numerical values. 20 | geometry: :class:`pybamm.Geometry` (optional) 21 | The geometry upon which to solve the model 22 | submesh_types: dict (optional) 23 | A dictionary of the types of submesh to use on each subdomain 24 | var_pts: dict (optional) 25 | A dictionary of the number of points used by each spatial variable 26 | spatial_methods: dict (optional) 27 | A dictionary of the types of spatial method to use on each 28 | domain (e.g. pybamm.FiniteVolume) 29 | """ 30 | 31 | def __init__( 32 | self, 33 | model, 34 | parameter_values=None, 35 | geometry=None, 36 | submesh_types=None, 37 | var_pts=None, 38 | spatial_methods=None, 39 | ): 40 | # Set attributes 41 | self.model_name = model.name 42 | self.set_up_time = None 43 | self.solve_time = None 44 | timer = pybamm.Timer() 45 | 46 | # Set up the model for EIS 47 | pybamm.logger.info(f"Start setting up {self.model_name} for EIS") 48 | self.model = self.set_up_model_for_eis(model) 49 | 50 | # Create and build a simulation to conviniently build the model 51 | parameter_values = parameter_values or model.default_parameter_values 52 | parameter_values["Current function [A]"] = 0 53 | sim = pybamm.Simulation( 54 | self.model, 55 | geometry=geometry, 56 | parameter_values=parameter_values, 57 | submesh_types=submesh_types, 58 | var_pts=var_pts, 59 | spatial_methods=spatial_methods, 60 | ) 61 | sim.build() 62 | self.built_model = sim.built_model 63 | 64 | # Extract mass matrix and Jacobian 65 | solver = pybamm.BaseSolver() 66 | solver.set_up(self.built_model) 67 | M = self.built_model.mass_matrix.entries 68 | self.y0 = self.built_model.concatenated_initial_conditions.entries 69 | J = self.built_model.jac_rhs_algebraic_eval( 70 | 0, self.y0, [] 71 | ).sparse() # call the Jacobian and return a (sparse) matrix 72 | # Convert to csc for efficiency in later methods 73 | self.M = csc_matrix(M) 74 | self.J = csc_matrix(J) 75 | # Add forcing on the current density variable, which is the 76 | # final entry by construction 77 | self.b = np.zeros_like(self.y0) 78 | self.b[-1] = -1 79 | # Store time and current scales 80 | self.timescale = self.built_model.timescale_eval 81 | self.current_scale = sim.parameter_values.evaluate(model.param.I_typ) 82 | 83 | # Set setup time 84 | self.set_up_time = timer.time() 85 | pybamm.logger.info(f"Finished setting up {self.model_name} for EIS") 86 | pybamm.logger.info(f"Set-up time: {self.set_up_time}") 87 | 88 | def set_up_model_for_eis(self, model): 89 | """ 90 | Set up model so that current and voltage are states. 91 | This formulation is suitable for EIS calculations in 92 | the frequency domain. 93 | 94 | Parameters 95 | ---------- 96 | model : :class:`pybamm.BaseModel` 97 | Model to set up for EIS. 98 | """ 99 | pybamm.logger.info("Start setting up {} for EIS".format(self.model_name)) 100 | 101 | # Make a new copy of the model 102 | new_model = model.new_copy() 103 | 104 | # Create a voltage variable 105 | V_cell = pybamm.Variable("Terminal voltage variable") 106 | new_model.variables["Terminal voltage variable"] = V_cell 107 | V = new_model.variables["Terminal voltage [V]"] 108 | # Add an algebraic equation for the voltage variable 109 | new_model.algebraic[V_cell] = V_cell - V 110 | new_model.initial_conditions[V_cell] = ( 111 | new_model.param.p.U_ref - new_model.param.n.U_ref 112 | ) 113 | 114 | # Now make current density a variable 115 | # To do so, we replace all instances of the current density in the 116 | # model with a current density variable, which is obtained from the 117 | # FunctionControl submodel 118 | 119 | # Create the FunctionControl submodel and extract variables 120 | external_circuit_variables = pybamm.external_circuit.FunctionControl( 121 | model.param, None, model.options, control="algebraic" 122 | ).get_fundamental_variables() 123 | 124 | # Perform the replacement 125 | symbol_replacement_map = { 126 | new_model.variables[name]: variable 127 | for name, variable in external_circuit_variables.items() 128 | } 129 | # Don't replace initial conditions, as these should not contain 130 | # Variable objects 131 | replacer = pbeis.SymbolReplacer( 132 | symbol_replacement_map, process_initial_conditions=False 133 | ) 134 | replacer.process_model(new_model, inplace=True) 135 | 136 | # Add an algebraic equation for the current density variable 137 | # External circuit submodels are always equations on the current 138 | i_cell = new_model.variables["Current density variable"] 139 | I = new_model.variables["Current [A]"] 140 | I_applied = pybamm.FunctionParameter( 141 | "Current function [A]", {"Time [s]": pybamm.t * new_model.param.timescale} 142 | ) 143 | new_model.algebraic[i_cell] = I - I_applied 144 | new_model.initial_conditions[i_cell] = 0 145 | 146 | pybamm.logger.info("Finish setting up {} for EIS".format(self.model_name)) 147 | 148 | return new_model 149 | 150 | def solve(self, frequencies, method="direct"): 151 | """ 152 | Compute the impedance at the given frequencies by solving problem 153 | 154 | .. math:: 155 | i \omega \tau M x = J x + b 156 | 157 | where i is the imagianary unit, \omega is the frequency, \tau is the model 158 | timescale, M is the mass matrix, J is the Jacobian, x is the state vector, 159 | and b gives a periodic forcing in the current. 160 | 161 | Parameters 162 | ---------- 163 | frequencies : array-like 164 | The frequencies at which to compute the impedance. 165 | method : str, optional 166 | The method used to calculate the impedance. Can be 'direct', 'prebicgstab', 167 | 'bicgstab' or 'cg'. Default is 'direct'. 168 | 169 | Returns 170 | ------- 171 | solution : array-like 172 | The impedances at the given frequencies. 173 | """ 174 | 175 | pybamm.logger.info(f"Start calculating impedance for {self.model_name}") 176 | timer = pybamm.Timer() 177 | 178 | if method == "direct": 179 | zs = [] 180 | for frequency in frequencies: 181 | A = 1.0j * 2 * np.pi * frequency * self.timescale * self.M - self.J 182 | lu = splu(A) 183 | x = lu.solve(self.b) 184 | # The model is set up such that the voltage is the penultimate 185 | # entry and the current density variable is the final entry 186 | z = -x[-2][0] / x[-1][0] 187 | zs.append(z) 188 | elif method in ["prebicgstab", "bicgstab", "cg"]: 189 | zs = self.iterative_method(frequencies, method=method) 190 | else: 191 | raise NotImplementedError( 192 | "'method' must be 'direct', 'prebicgstab', 'bicgstab' or 'cg', ", 193 | f"but is '{method}'", 194 | ) 195 | 196 | # Note: the current density variable is dimensionless so we need 197 | # to scale by the current scale from the model to get true impedance 198 | self.solution = np.array(zs) / self.current_scale 199 | 200 | # Store solve time as an attribute 201 | self.solve_time = timer.time() 202 | pybamm.logger.info(f"Finished calculating impedance for {self.model_name}") 203 | pybamm.logger.info(f"Solve time: {self.solve_time}") 204 | 205 | return self.solution 206 | 207 | def iterative_method(self, frequencies, method="prebicgstab"): 208 | """ 209 | Compute the impedance at the given frequencies by solving problem 210 | 211 | .. math:: 212 | i \omega \tau M x = J x + b 213 | 214 | using an iterative method, where i is the imagianary unit, \omega 215 | is the frequency, \tau is the model timescale, M is the mass matrix, 216 | J is the Jacobian, x is the state vector, and b gives a periodic 217 | forcing in the current. 218 | 219 | Parameters 220 | ---------- 221 | frequencies : array-like 222 | The frequencies at which to compute the impedance. 223 | method : str, optional 224 | The method used to calculate the impedance. Can be: 225 | 'cg' - conjugate gradient - only use for Hermitian matrices 226 | 'bicgstab' - use bicgstab with no preconditioner 227 | 'prebicgstab' - use bicgstab with a preconditioner, this is 228 | the default. 229 | Returns 230 | ------- 231 | zs : array-like 232 | The impedances at the given frequencies. 233 | """ 234 | # Allocate solve times for preconditioner 235 | if method == "prebicgstab": 236 | lu_time = 0 237 | solve_time = 0 238 | 239 | # Loop over frequencies 240 | zs = [] 241 | sol = self.b 242 | iters_per_frequency = [] 243 | 244 | for frequency in frequencies: 245 | # Reset per-frequency iteration counter 246 | num_iters = 0 247 | 248 | # Construct the matrix A(frequency) 249 | A = 1.0j * 2 * np.pi * frequency * self.timescale * self.M - self.J 250 | 251 | def callback(xk): 252 | """ 253 | Increments the number of iterations in the call to the 'method' 254 | functions. 255 | """ 256 | nonlocal num_iters 257 | num_iters += 1 258 | 259 | if method == "bicgstab": 260 | sol = pbeis.bicgstab(A, self.b, start_point=sol, callback=callback) 261 | elif method == "prebicgstab": 262 | # Update preconditioner based on solve time 263 | if lu_time <= solve_time: 264 | lu_start_time = time.process_time() 265 | lu = splu(A) 266 | sol = lu.solve(self.b) 267 | lu_time = time.process_time() - lu_start_time 268 | 269 | # Solve 270 | solve_start_time = time.process_time() 271 | sol = pbeis.prebicgstab( 272 | A, self.b, lu, start_point=sol, callback=callback 273 | ) 274 | solve_time = time.process_time() - solve_start_time 275 | 276 | elif method == "cg": 277 | sol = pbeis.conjugate_gradient( 278 | A, self.b, start_point=sol, callback=callback 279 | ) 280 | 281 | # Store number of iterations at this frequency 282 | iters_per_frequency.append(num_iters) 283 | 284 | # The model is set up such that the voltage is the penultimate 285 | # entry and the current density variable is the final entry 286 | z = -sol[-2][0] / sol[-1][0] 287 | zs.append(z) 288 | 289 | return zs 290 | 291 | def nyquist_plot(self, ax=None, marker="o", linestyle="None", **kwargs): 292 | """ 293 | A method to quickly creates a nyquist plot using the results of the simulation. 294 | Calls :meth:`pbeis.nyquist_plot`. 295 | 296 | Parameters 297 | ---------- 298 | ax : matplotlib Axis, optional 299 | The axis on which to put the plot. If None, a new figure 300 | and axis is created. 301 | marker : str, optional 302 | The marker to use for the plot. Default is 'o' 303 | linestyle : str, optional 304 | The linestyle to use for the plot. Default is 'None' 305 | kwargs 306 | Keyword arguments, passed to plt.scatter. 307 | """ 308 | return pbeis.nyquist_plot( 309 | self.solution, ax=None, marker=marker, linestyle=linestyle, **kwargs 310 | ) 311 | -------------------------------------------------------------------------------- /pbeis/numerical_methods.py: -------------------------------------------------------------------------------- 1 | # 2 | # Linear algebra methods 3 | # 4 | 5 | import numpy as np 6 | import scipy.sparse 7 | 8 | 9 | def empty(): 10 | # An empty callback function. Callbacks can be written as desired. 11 | pass 12 | 13 | 14 | def conjugate_gradient(A, b, start_point=None, callback=empty, tol=1e-3): 15 | """ 16 | Uses the conjugate gradient method to solve Ax = b. Should not be used 17 | unless A is Hermitian. If A is not hermitian, use BicgSTAB instead. 18 | For best performance A should be a scipy csr sparse matrix. 19 | 20 | Parameters 21 | ---------- 22 | A : scipy sparse csr matrix 23 | A square matrix. 24 | b : numpy nx1 array 25 | start_point : numpy nx1 array, optional 26 | Where the iteration starts. If not provided the initial guess will be zero. 27 | callback : function, optional 28 | a function callback(xk) that can be written to happen each iteration. 29 | The default is empty. 30 | tol : float, optional 31 | A tolerance at which to stop the iteration. The default is 1e-3. 32 | 33 | Returns 34 | ------- 35 | xk : numpy nx1 array 36 | The solution of Ax = b. 37 | 38 | """ 39 | 40 | # The default start point is b unless specified otherwise 41 | if start_point is None: 42 | start_point = np.zeros_like(b) 43 | 44 | xk = np.array(start_point) 45 | # Find the residual and set the search direction to the residual 46 | rk = b - A @ xk 47 | pk = rk 48 | 49 | max_num_iter = np.shape(b)[0] 50 | rk1rk1 = np.dot(np.conj(rk), rk) 51 | 52 | # start the iterative step 53 | for k in range(max_num_iter): 54 | # Find alpha_k, the distance to move in the search direction 55 | Apk = A @ pk 56 | rkrk = rk1rk1 57 | pkApk = np.dot(np.conj(pk), Apk) 58 | 59 | alpha_k = rkrk / pkApk 60 | 61 | xk = xk + alpha_k * pk 62 | 63 | # run the callback 64 | callback(xk) 65 | 66 | # Stop if the change in the last entry is under tolerance 67 | if alpha_k * pk[-1] < tol: 68 | break 69 | else: 70 | # Find the new residual 71 | rk = rk - alpha_k * Apk 72 | 73 | rk1rk1 = np.dot(np.conj(rk), rk) 74 | 75 | beta_k = rk1rk1 / rkrk 76 | 77 | # Update the search direction 78 | pk = rk + beta_k * pk 79 | 80 | return xk 81 | 82 | 83 | def bicgstab(A, b, start_point=None, callback=empty, tol=10**-3): 84 | """ 85 | Uses the BicgSTAB method to solve Ax = b 86 | 87 | Parameters 88 | ---------- 89 | A : scipy sparse csr matrix 90 | A square matrix. 91 | b : numpy nx1 array 92 | start_point : numpy nx1 array, optional 93 | Where the iteration starts. If not provided the initial guess will be zero. 94 | callback : function, optional 95 | a function callback(xk) that can be written to happen each iteration. 96 | The default is empty. 97 | tol : float, optional 98 | A tolerance at which to stop the iteration. The default is 10**-3. 99 | 100 | Returns 101 | ------- 102 | xk : numpy nx1 array 103 | The solution of Ax = b. 104 | 105 | """ 106 | # The default start point is b unless specified otherwise 107 | if start_point is None: 108 | start_point = np.zeros_like(b) 109 | 110 | xk = np.array(start_point) 111 | # Find the residual 112 | rk = b - A @ xk 113 | r0 = np.conj(rk) 114 | rhok = 1 115 | alpha_k = 1 116 | wk = 1 117 | 118 | # set the search direction to the residual 119 | pk = np.zeros(np.shape(b)) 120 | vk = pk 121 | 122 | # Since bicgstab uses cg on a matrix of size 2n, set the max number of 123 | # iterations as follows 124 | max_num_iter = 2 * np.shape(b)[0] 125 | 126 | for k in range(1, max_num_iter + 1): 127 | # Calculate the next search direction pk 128 | rhok1 = rhok 129 | rhok = np.dot(r0.T, rk) 130 | beta_k = (rhok / rhok1) * (alpha_k / wk) 131 | 132 | pk = rk + beta_k * (pk - wk * vk) 133 | vk = A @ pk 134 | 135 | # Calculate the distance to move in the pk direction 136 | alpha_k = rhok / np.dot(r0.T, vk) 137 | 138 | # Move alpha_k in the pk direction 139 | h = xk + alpha_k * pk 140 | 141 | s = rk - alpha_k * vk 142 | t = A @ s 143 | 144 | wk = np.dot(np.conj(t.T), s) / np.dot(np.conj(t.T), t) 145 | # Update xk 146 | xk = h + wk * s 147 | # Run the callback 148 | callback(xk) 149 | 150 | # Check whether the 1-norm of the residual is less than the tolerance 151 | if np.linalg.norm(rk, 1) < tol: 152 | break 153 | else: 154 | # Update the residual 155 | rk = s - wk * t 156 | return xk 157 | 158 | 159 | def prebicgstab(A, b, LU, start_point=None, callback=empty, tol=1e-3): 160 | """ 161 | Uses the preconditioned BicgSTAB method to solve Ax = b. The preconditioner 162 | is of the form LU, or just of the form L. 163 | 164 | Parameters 165 | ---------- 166 | A : scipy sparse csr matrix 167 | A square matrix. 168 | b : numpy nx1 array 169 | LU : scipy sparse csr matrix 170 | The LU decomposition (typically a superLU object) 171 | start_point : numpy nx1 array, optional 172 | Where the iteration starts. If not provided the initial guess will be zero. 173 | callback : function, optional 174 | a function callback(xk) that can be written to happen each iteration. 175 | The default is empty. 176 | tol : float, optional 177 | A tolerance at which to stop the iteration. The default is 1e-3. 178 | 179 | Returns 180 | ------- 181 | xk : numpy nx1 array 182 | The solution of Ax = b. 183 | 184 | """ 185 | # The default start point is b unless specified otherwise 186 | if start_point is None: 187 | start_point = np.zeros_like(b) 188 | 189 | xk = np.array(start_point) 190 | # Find the residual 191 | rk = b - A @ xk 192 | 193 | r0 = np.conj(rk) 194 | 195 | rhok = 1 196 | alpha_k = 1 197 | wk = 1 198 | 199 | pk = np.zeros_like(b) 200 | vk = pk 201 | 202 | # Since bicgstab uses cg on a matrix of size 2n, set the max number of 203 | # iterations as follows 204 | max_num_iter = 2 * np.shape(b)[0] 205 | 206 | # Check the format of LU (super LU or scipy sparse matrix) 207 | if type(LU) == scipy.sparse.linalg.SuperLU: 208 | superLU = True 209 | else: 210 | superLU = False 211 | 212 | # Start the iterative step 213 | for k in range(1, max_num_iter + 1): 214 | # Calculate the search direction pk 215 | rhok1 = rhok 216 | rhok = np.dot(r0.T, rk) 217 | beta_k = (rhok / rhok1) * (alpha_k / wk) 218 | pk = rk + beta_k * (pk - wk * vk) 219 | 220 | # Use the preconditioning to solve LUy = pk. Do this depending 221 | # on the format of L. 222 | if superLU: 223 | y = np.array(LU.solve(pk)) 224 | else: 225 | y = scipy.sparse.linalg.spsolve(LU, pk) 226 | 227 | # Reshape y to a nx1 matrix, so the rest of the calculations can be done. 228 | y = np.reshape(y, np.shape(b)) 229 | 230 | vk = A @ y 231 | alpha_k = rhok / np.dot(r0.T, vk) 232 | 233 | h = xk + alpha_k * y 234 | 235 | s = rk - alpha_k * vk 236 | 237 | s = rk - alpha_k * vk 238 | 239 | # Perform the preconditioning to solve LUz = s. Do this depending 240 | # on the format of L. 241 | if superLU: 242 | z = np.array(LU.solve(s)) 243 | else: 244 | z = scipy.sparse.linalg.spsolve(LU, s) 245 | 246 | # Reshape z to a nx1 matrix, so the rest of the calculations can be done. 247 | z = np.reshape(z, np.shape(b)) 248 | 249 | t = A @ z 250 | 251 | wk = np.dot(np.conj(t.T), s) / np.dot(np.conj(t.T), t) 252 | 253 | # Update xk 254 | xk = h + wk * z 255 | 256 | # Run the callback 257 | callback(xk) 258 | 259 | # Check whether the 1-norm of the residual is less than the tolerance 260 | if np.linalg.norm(rk, 1) < tol: 261 | break 262 | else: 263 | # Update the residual 264 | rk = s - wk * t 265 | return xk 266 | 267 | 268 | def matrix_rescale(M, J, b): 269 | """ 270 | Rescale the matrices to increase the convergence of the last 2 entries 271 | by increasing their weight. 272 | 273 | Parameters 274 | ---------- 275 | M : scipy sparse csr matrix 276 | Mass matrix 277 | J : scipy sparse csr matrix 278 | Jacobian 279 | b : numpy nx1 280 | the RHS 281 | 282 | Returns 283 | ------- 284 | M : scipy sparse csr matrix 285 | Mass matrix 286 | J : scipy sparse csr matrix 287 | Jacobian 288 | b : numpy nx1 289 | the RHS 290 | 291 | """ 292 | 293 | n = np.shape(M)[0] 294 | # set a multiplier to multiply the rows. All are multiplied by 1 except the 295 | # last 2. The last row is multiplied by 2 and second last by 3. 296 | multiplier = np.ones(n, dtype="complex") 297 | 298 | multiplier[-2] = 3 299 | multiplier[-1] = 2 300 | 301 | # Make the multiplier a nxn matrix with elements on the diagonal to scale 302 | # the rows. 303 | multiplier = scipy.sparse.diags(multiplier) 304 | M = multiplier @ M 305 | J = multiplier @ J 306 | b = multiplier @ b 307 | 308 | return M, J, b 309 | -------------------------------------------------------------------------------- /pbeis/plotting.py: -------------------------------------------------------------------------------- 1 | # 2 | # Functions for plotting 3 | # 4 | import matplotlib.pyplot as plt 5 | import numpy as np 6 | 7 | 8 | def nyquist_plot(data, ax=None, marker="o", linestyle="None", **kwargs): 9 | """ 10 | Generates a Nyquist plot from data. Calls `matplotlib.pyplot.plot` 11 | with keyword arguments 'kwargs'. For a list of 'kwargs' see the 12 | `matplotlib plot documentation `_ 13 | 14 | Parameters 15 | ---------- 16 | data : list or array-like 17 | The data to be plotted. 18 | ax : matplotlib Axis, optional 19 | The axis on which to put the plot. If None, a new figure 20 | and axis is created. 21 | marker : str, optional 22 | The marker to use for the plot. Default is 'o' 23 | linestyle : str, optional 24 | The linestyle to use for the plot. Default is 'None' 25 | kwargs 26 | Keyword arguments, passed to plt.scatter. 27 | """ 28 | 29 | if isinstance(data, list): 30 | data = np.array(data) 31 | 32 | if ax is None: 33 | _, ax = plt.subplots() 34 | show = True 35 | else: 36 | show = False 37 | 38 | ax.plot(data.real, -data.imag, marker=marker, linestyle=linestyle, **kwargs) 39 | ax.set_xlabel(r"$Z_\mathrm{Re}$ [Ohm]") 40 | ax.set_ylabel(r"$-Z_\mathrm{Im}$ [Ohm]") 41 | 42 | if show: 43 | plt.show() 44 | 45 | return ax 46 | -------------------------------------------------------------------------------- /pbeis/utils.py: -------------------------------------------------------------------------------- 1 | # 2 | # Replace a symbol 3 | # 4 | import pybamm 5 | import numpy as np 6 | 7 | 8 | class SymbolReplacer(object): 9 | """ 10 | Helper class to replace all instances of one or more symbols in an expression tree 11 | with another symbol, as defined by the dictionary `symbol_replacement_map` 12 | 13 | Parameters 14 | ---------- 15 | symbol_replacement_map : dict {:class:`pybamm.Symbol` -> :class:`pybamm.Symbol`} 16 | Map of which symbols should be replaced by which. 17 | processed_symbols: dict {:class:`pybamm.Symbol` -> :class:`pybamm.Symbol`}, optional 18 | cached replaced symbols 19 | process_initial_conditions: bool, optional 20 | Whether to process initial conditions, default is True 21 | """ 22 | 23 | def __init__( 24 | self, 25 | symbol_replacement_map, 26 | processed_symbols=None, 27 | process_initial_conditions=True, 28 | ): 29 | self._symbol_replacement_map = symbol_replacement_map 30 | self._processed_symbols = processed_symbols or {} 31 | self.process_initial_conditions = process_initial_conditions 32 | 33 | def process_model(self, unprocessed_model, inplace=True): 34 | """Replace all instances of a symbol in a model. 35 | 36 | Parameters 37 | ---------- 38 | unprocessed_model : :class:`pybamm.BaseModel` 39 | Model to assign parameter values for 40 | inplace: bool, optional 41 | If True, replace the parameters in the model in place. Otherwise, return a 42 | new model with parameter values set. Default is True. 43 | """ 44 | pybamm.logger.info( 45 | "Start replacing symbols in {}".format(unprocessed_model.name) 46 | ) 47 | 48 | # set up inplace vs not inplace 49 | if inplace: 50 | # any changes to unprocessed_model attributes will change model attributes 51 | # since they point to the same object 52 | model = unprocessed_model 53 | else: 54 | # create a copy of the model 55 | model = unprocessed_model.new_copy() 56 | 57 | new_rhs = {} 58 | for variable, equation in unprocessed_model.rhs.items(): 59 | pybamm.logger.verbose("Replacing symbols in {!r} (rhs)".format(variable)) 60 | new_rhs[self.process_symbol(variable)] = self.process_symbol(equation) 61 | model.rhs = new_rhs 62 | 63 | new_algebraic = {} 64 | for variable, equation in unprocessed_model.algebraic.items(): 65 | pybamm.logger.verbose( 66 | "Replacing symbols in {!r} (algebraic)".format(variable) 67 | ) 68 | new_algebraic[self.process_symbol(variable)] = self.process_symbol(equation) 69 | model.algebraic = new_algebraic 70 | 71 | new_initial_conditions = {} 72 | for variable, equation in unprocessed_model.initial_conditions.items(): 73 | pybamm.logger.verbose( 74 | "Replacing symbols in {!r} (initial conditions)".format(variable) 75 | ) 76 | if self.process_initial_conditions: 77 | new_initial_conditions[ 78 | self.process_symbol(variable) 79 | ] = self.process_symbol(equation) 80 | else: 81 | new_initial_conditions[self.process_symbol(variable)] = equation 82 | model.initial_conditions = new_initial_conditions 83 | 84 | model.boundary_conditions = self.process_boundary_conditions(unprocessed_model) 85 | 86 | new_variables = {} 87 | for variable, equation in unprocessed_model.variables.items(): 88 | pybamm.logger.verbose( 89 | "Replacing symbols in {!r} (variables)".format(variable) 90 | ) 91 | new_variables[variable] = self.process_symbol(equation) 92 | model.variables = new_variables 93 | 94 | new_events = [] 95 | for event in unprocessed_model.events: 96 | pybamm.logger.verbose("Replacing symbols in event'{}''".format(event.name)) 97 | new_events.append( 98 | pybamm.Event( 99 | event.name, self.process_symbol(event.expression), event.event_type 100 | ) 101 | ) 102 | model.events = new_events 103 | 104 | # Set external variables 105 | model.external_variables = [ 106 | self.process_symbol(var) for var in unprocessed_model.external_variables 107 | ] 108 | 109 | # Process timescale 110 | model._timescale = self.process_symbol(unprocessed_model.timescale) 111 | 112 | # Process length scales 113 | new_length_scales = {} 114 | for domain, scale in unprocessed_model.length_scales.items(): 115 | new_length_scales[domain] = self.process_symbol(scale) 116 | model._length_scales = new_length_scales 117 | 118 | pybamm.logger.info("Finish replacing symbols in {}".format(model.name)) 119 | 120 | return model 121 | 122 | def process_boundary_conditions(self, model): 123 | """ 124 | Process boundary conditions for a model 125 | Boundary conditions are dictionaries {"left": left bc, "right": right bc} 126 | in general, but may be imposed on the tabs (or *not* on the tab) for a 127 | small number of variables, e.g. {"negative tab": neg. tab bc, 128 | "positive tab": pos. tab bc "no tab": no tab bc}. 129 | """ 130 | new_boundary_conditions = {} 131 | sides = ["left", "right", "negative tab", "positive tab", "no tab"] 132 | for variable, bcs in model.boundary_conditions.items(): 133 | processed_variable = self.process_symbol(variable) 134 | new_boundary_conditions[processed_variable] = {} 135 | for side in sides: 136 | try: 137 | bc, typ = bcs[side] 138 | pybamm.logger.verbose( 139 | "Replacing symbols in {!r} ({} bc)".format(variable, side) 140 | ) 141 | processed_bc = (self.process_symbol(bc), typ) 142 | new_boundary_conditions[processed_variable][side] = processed_bc 143 | except KeyError as err: 144 | # don't raise error if the key error comes from the side not being 145 | # found 146 | if err.args[0] in side: 147 | pass 148 | # do raise error otherwise (e.g. can't process symbol) 149 | else: # pragma: no cover 150 | raise KeyError(err) 151 | 152 | return new_boundary_conditions 153 | 154 | def process_symbol(self, symbol): 155 | """ 156 | This function recurses down the tree, replacing any symbols in 157 | self._symbol_replacement_map.keys() with their corresponding value 158 | 159 | Parameters 160 | ---------- 161 | symbol : :class:`pybamm.Symbol` 162 | The symbol to replace 163 | 164 | Returns 165 | ------- 166 | :class:`pybamm.Symbol` 167 | Symbol with all replacements performed 168 | """ 169 | 170 | try: 171 | return self._processed_symbols[symbol] 172 | except KeyError: 173 | replaced_symbol = self._process_symbol(symbol) 174 | 175 | self._processed_symbols[symbol] = replaced_symbol 176 | 177 | return replaced_symbol 178 | 179 | def _process_symbol(self, symbol): 180 | """See :meth:`Simplification.process_symbol()`.""" 181 | if symbol in self._symbol_replacement_map.keys(): 182 | return self._symbol_replacement_map[symbol] 183 | 184 | elif isinstance(symbol, pybamm.BinaryOperator): 185 | left, right = symbol.children 186 | # process children 187 | new_left = self.process_symbol(left) 188 | new_right = self.process_symbol(right) 189 | # Return a new copy with the replaced symbols 190 | return symbol._binary_new_copy(new_left, new_right) 191 | 192 | elif isinstance(symbol, pybamm.UnaryOperator): 193 | new_child = self.process_symbol(symbol.child) 194 | # Return a new copy with the replaced symbols 195 | return symbol._unary_new_copy(new_child) 196 | 197 | elif isinstance(symbol, pybamm.Function): 198 | new_children = [self.process_symbol(child) for child in symbol.children] 199 | # Return a new copy with the replaced symbols 200 | return symbol._function_new_copy(new_children) 201 | 202 | elif isinstance(symbol, pybamm.Concatenation): 203 | new_children = [self.process_symbol(child) for child in symbol.children] 204 | # Return a new copy with the replaced symbols 205 | return symbol._concatenation_new_copy(new_children) 206 | 207 | else: 208 | # Only other option is that the symbol is a leaf (doesn't have children) 209 | # In this case, since we have already ruled out that the symbol is one of 210 | # the symbols that needs to be replaced, we can just return the symbol 211 | return symbol 212 | 213 | 214 | def logspace(start, stop, num=50, **kwargs): 215 | """ 216 | Return numbers spaced evenly on a log scale. Calls numpy.logspace` 217 | with keyword arguments 'kwargs'. For a list of 'kwargs' see the 218 | `matplotlib plot documentation `_ 219 | 220 | Parameters 221 | ---------- 222 | start : array_like 223 | ``base ** start`` is the starting value of the sequence. 224 | stop : array_like 225 | ``base ** stop`` is the final value of the sequence, unless `endpoint` 226 | is False. In that case, ``num + 1`` values are spaced over the 227 | interval in log-space, of which all but the last (a sequence of 228 | length `num`) are returned. 229 | num : integer, optional 230 | Number of samples to generate. Default is 50. 231 | """ 232 | return np.logspace(start, stop, num, **kwargs) 233 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | # Load text for description and license 4 | with open("README.md", encoding="utf-8") as f: 5 | readme = f.read() 6 | 7 | setup( 8 | name="pbeis", 9 | version="0.1.0", 10 | description="PyBaMM EIS", 11 | long_description=readme, 12 | long_description_content_type="text/markdown", 13 | url="https://github.com/rish31415/pybamm-eis", 14 | packages=find_packages(include=("pbeis")), 15 | author="Rishit Dhoot & Robert Timms", 16 | author_email="timms@maths.ox.ac.uk", 17 | license="LICENSE", 18 | install_requires=["pybamm == 22.10", "matplotlib"], 19 | ) 20 | --------------------------------------------------------------------------------