from .fitting import circuit_fit, buildCircuit
from .fitting import calculateCircuitLength, check_and_eval
from impedance.visualization import plot_altair, plot_bode, plot_nyquist
from .elements import circuit_elements, get_element_from_name
import json
import matplotlib.pyplot as plt
import numpy as np
import warnings
[docs]
class BaseCircuit:
""" Base class for equivalent circuit models """
def __init__(self, initial_guess=[], constants=None, name=None):
""" Base constructor for any equivalent circuit model
Parameters
----------
initial_guess: numpy array
Initial guess of the circuit values
constants : dict, optional
Parameters and values to hold constant during fitting
(e.g. {"R0": 0.1})
name : str, optional
Name for the circuit
"""
# if supplied, check that initial_guess is valid and store
initial_guess = [x for x in initial_guess if x is not None]
for i in initial_guess:
if not isinstance(i, (float, int, np.int32, np.float64)):
raise TypeError(f'value {i} in initial_guess is not a number')
# initalize class attributes
self.initial_guess = initial_guess
if constants is not None:
self.constants = constants
else:
self.constants = {}
self.name = name
# initialize fit parameters and confidence intervals
self.parameters_ = None
self.conf_ = None
def __eq__(self, other):
if self.__class__ == other.__class__:
matches = []
for key, value in self.__dict__.items():
if isinstance(value, np.ndarray):
matches.append((value == other.__dict__[key]).all())
else:
matches.append(value == other.__dict__[key])
return np.array(matches).all()
else:
raise TypeError('Comparing object is not of the same type.')
[docs]
def fit(self, frequencies, impedance, bounds=None,
weight_by_modulus=False, **kwargs):
""" Fit the circuit model
Parameters
----------
frequencies: numpy array
Frequencies
impedance: numpy array of dtype 'complex128'
Impedance values to fit
bounds: 2-tuple of array_like, optional
Lower and upper bounds on parameters. Defaults to bounds on all
parameters of 0 and np.inf, except the CPE alpha
which has an upper bound of 1
weight_by_modulus : bool, optional
Uses the modulus of each data (|Z|) as the weighting factor.
Standard weighting scheme when experimental variances are
unavailable. Only applicable when global_opt = False
kwargs :
Keyword arguments passed to
impedance.models.circuits.fitting.circuit_fit,
and subsequently to scipy.optimize.curve_fit
or scipy.optimize.basinhopping
Returns
-------
self: returns an instance of self
"""
frequencies = np.array(frequencies, dtype=float)
impedance = np.array(impedance, dtype=complex)
if len(frequencies) != len(impedance):
raise TypeError('length of frequencies and impedance do not match')
if self.initial_guess != []:
parameters, conf = circuit_fit(frequencies, impedance,
self.circuit, self.initial_guess,
constants=self.constants,
bounds=bounds,
weight_by_modulus=weight_by_modulus,
**kwargs)
self.parameters_ = parameters
if conf is not None:
self.conf_ = conf
else:
raise ValueError('No initial guess supplied')
return self
def _is_fit(self):
""" check if model has been fit (parameters_ is not None) """
if self.parameters_ is not None:
return True
else:
return False
[docs]
def predict(self, frequencies, use_initial=False):
""" Predict impedance using an equivalent circuit model
Parameters
----------
frequencies: array-like of numeric type
use_initial: boolean
If true and the model was previously fit use the initial
parameters instead
Returns
-------
impedance: ndarray of dtype 'complex128'
Predicted impedance at each frequency
"""
frequencies = np.array(frequencies, dtype=float)
if self._is_fit() and not use_initial:
return eval(buildCircuit(self.circuit, frequencies,
*self.parameters_,
constants=self.constants, eval_string='',
index=0)[0],
circuit_elements)
else:
warnings.warn("Simulating circuit based on initial parameters")
return eval(buildCircuit(self.circuit, frequencies,
*self.initial_guess,
constants=self.constants, eval_string='',
index=0)[0],
circuit_elements)
[docs]
def get_param_names(self):
""" Converts circuit string to names and units """
# parse the element names from the circuit string
names = self.circuit.replace('p', '').replace('(', '').replace(')', '')
names = names.replace(',', '-').replace(' ', '').split('-')
full_names, all_units = [], []
for name in names:
elem = get_element_from_name(name)
num_params = check_and_eval(elem).num_params
units = check_and_eval(elem).units
if num_params > 1:
for j in range(num_params):
full_name = '{}_{}'.format(name, j)
if full_name not in self.constants.keys():
full_names.append(full_name)
all_units.append(units[j])
else:
if name not in self.constants.keys():
full_names.append(name)
all_units.append(units[0])
return full_names, all_units
def __str__(self):
""" Defines the pretty printing of the circuit"""
to_print = '\n'
if self.name is not None:
to_print += 'Name: {}\n'.format(self.name)
to_print += 'Circuit string: {}\n'.format(self.circuit)
to_print += "Fit: {}\n".format(self._is_fit())
if len(self.constants) > 0:
to_print += '\nConstants:\n'
for name, value in self.constants.items():
elem = get_element_from_name(name)
units = check_and_eval(elem).units
if '_' in name and len(units) > 1:
unit = units[int(name.split('_')[-1])]
else:
unit = units[0]
to_print += ' {:>5} = {:.2e} [{}]\n'.format(name, value, unit)
names, units = self.get_param_names()
to_print += '\nInitial guesses:\n'
for name, unit, param in zip(names, units, self.initial_guess):
to_print += ' {:>5} = {:.2e} [{}]\n'.format(name, param, unit)
if self._is_fit():
params, confs = self.parameters_, self.conf_
to_print += '\nFit parameters:\n'
for name, unit, param, conf in zip(names, units, params, confs):
to_print += ' {:>5} = {:.2e}'.format(name, param)
to_print += ' (+/- {:.2e}) [{}]\n'.format(conf, unit)
return to_print
[docs]
def plot(self, ax=None, f_data=None, Z_data=None, kind='altair', **kwargs):
""" visualizes the model and optional data as a nyquist,
bode, or altair (interactive) plots
Parameters
----------
ax: matplotlib.axes
axes to plot on
f_data: np.array of type float
Frequencies of input data (for Bode plots)
Z_data: np.array of type complex
Impedance data to plot
kind: {'altair', 'nyquist', 'bode'}
type of plot to visualize
Other Parameters
----------------
**kwargs : optional
If kind is 'nyquist' or 'bode', used to specify additional
`matplotlib.pyplot.Line2D` properties like linewidth,
line color, marker color, and labels.
If kind is 'altair', used to specify nyquist height as `size`
Returns
-------
ax: matplotlib.axes
axes of the created nyquist plot
"""
if kind == 'nyquist':
if ax is None:
_, ax = plt.subplots(figsize=(5, 5))
if Z_data is not None:
ax = plot_nyquist(Z_data, ls='', marker='s', ax=ax, **kwargs)
if self._is_fit():
if f_data is not None:
f_pred = f_data
else:
f_pred = np.logspace(5, -3)
Z_fit = self.predict(f_pred)
ax = plot_nyquist(Z_fit, ls='-', marker='', ax=ax, **kwargs)
return ax
elif kind == 'bode':
if ax is None:
_, ax = plt.subplots(nrows=2, figsize=(5, 5))
if f_data is not None:
f_pred = f_data
else:
f_pred = np.logspace(5, -3)
if Z_data is not None:
if f_data is None:
raise ValueError('f_data must be specified if' +
' Z_data for a Bode plot')
ax = plot_bode(f_data, Z_data, ls='', marker='s',
axes=ax, **kwargs)
if self._is_fit():
Z_fit = self.predict(f_pred)
ax = plot_bode(f_pred, Z_fit, ls='-', marker='',
axes=ax, **kwargs)
return ax
elif kind == 'altair':
plot_dict = {}
if Z_data is not None and f_data is not None:
plot_dict['data'] = {'f': f_data, 'Z': Z_data}
if self._is_fit():
if f_data is not None:
f_pred = f_data
else:
f_pred = np.logspace(5, -3)
Z_fit = self.predict(f_pred)
if self.name is not None:
name = self.name
else:
name = 'fit'
plot_dict[name] = {'f': f_pred, 'Z': Z_fit, 'fmt': '-'}
chart = plot_altair(plot_dict, **kwargs)
return chart
else:
raise ValueError("Kind must be one of 'altair'," +
f"'nyquist', or 'bode' (received {kind})")
[docs]
def save(self, filepath):
""" Exports a model to JSON
Parameters
----------
filepath: str
Destination for exporting model object
"""
model_string = self.circuit
model_name = self.name
initial_guess = self.initial_guess
if self._is_fit():
parameters_ = list(self.parameters_)
model_conf_ = list(self.conf_)
data_dict = {"Name": model_name,
"Circuit String": model_string,
"Initial Guess": initial_guess,
"Constants": self.constants,
"Fit": True,
"Parameters": parameters_,
"Confidence": model_conf_,
}
else:
data_dict = {"Name": model_name,
"Circuit String": model_string,
"Initial Guess": initial_guess,
"Constants": self.constants,
"Fit": False}
with open(filepath, 'w') as f:
json.dump(data_dict, f)
[docs]
def load(self, filepath, fitted_as_initial=False):
""" Imports a model from JSON
Parameters
----------
filepath: str
filepath to JSON file to load model from
fitted_as_initial: bool
If true, loads the model's fitted parameters
as initial guesses
Otherwise, loads the model's initial and
fitted parameters as a completed model
"""
json_data_file = open(filepath, 'r')
json_data = json.load(json_data_file)
model_name = json_data["Name"]
model_string = json_data["Circuit String"]
model_initial_guess = json_data["Initial Guess"]
model_constants = json_data["Constants"]
self.initial_guess = model_initial_guess
self.circuit = model_string
print(self.circuit)
self.constants = model_constants
self.name = model_name
if json_data["Fit"]:
if fitted_as_initial:
self.initial_guess = np.array(json_data['Parameters'])
else:
self.parameters_ = np.array(json_data["Parameters"])
self.conf_ = np.array(json_data["Confidence"])
[docs]
class Randles(BaseCircuit):
""" A Randles circuit model class """
def __init__(self, CPE=False, **kwargs):
""" Constructor for the Randles' circuit class
Parameters
----------
initial_guess: numpy array
Initial guess of the circuit values
CPE: boolean
Use a constant phase element instead of a capacitor
"""
super().__init__(**kwargs)
if CPE:
self.name = 'Randles w/ CPE'
self.circuit = 'R0-p(R1-Wo1,CPE1)'
else:
self.name = 'Randles'
self.circuit = 'R0-p(R1-Wo1,C1)'
circuit_len = calculateCircuitLength(self.circuit)
if len(self.initial_guess) + len(self.constants) != circuit_len:
raise ValueError('The number of initial guesses ' +
f'({len(self.initial_guess)}) + ' +
'the number of constants ' +
f'({len(self.constants)})' +
' must be equal to ' +
f'the circuit length ({circuit_len})')
[docs]
class CustomCircuit(BaseCircuit):
def __init__(self, circuit='', **kwargs):
""" Constructor for a customizable equivalent circuit model
Parameters
----------
initial_guess: numpy array
Initial guess of the circuit values
circuit: string
A string that should be interpreted as an equivalent circuit
Notes
-----
A custom circuit is defined as a string comprised of elements in series
(separated by a `-`) and elements in parallel (grouped as (x,y)).
Each element can be appended with an integer (e.g. R0) or an underscore
and an integer (e.g. CPE_1) to make keeping track of multiple elements
of the same type easier.
Example:
Randles circuit is given by 'R0-p(R1-Wo1,C1)'
"""
super().__init__(**kwargs)
self.circuit = circuit.replace(" ", "")
circuit_len = calculateCircuitLength(self.circuit)
if len(self.initial_guess) + len(self.constants) != circuit_len:
raise ValueError('The number of initial guesses ' +
f'({len(self.initial_guess)}) + ' +
'the number of constants ' +
f'({len(self.constants)})' +
' must be equal to ' +
f'the circuit length ({circuit_len})')