# The ImpedanceFitter is a package to fit impedance spectra to
#
# equivalent-circuit models using open-source software.
#
# Copyright (C) 2018, 2019 Leonard Thiele, leonard.thiele[AT]uni-rostock.de
# Copyright (C) 2018, 2019, 2020 Julius Zimmermann,
# julius.zimmermann[AT]uni-rostock.de
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import logging
import os
from copy import deepcopy
import lmfit
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import yaml
from .plotting import (
plot_bode,
plot_impedance,
plot_resistance_capacitance,
plot_uncertainty,
)
from .readin import (
readin_Data_from_collection,
readin_Data_from_csv_E4980AL,
readin_Data_from_dataframe,
readin_Data_from_dta,
readin_Data_from_TXT_file,
)
from .utils import (
_return_resistance_capacitance,
get_equivalent_circuit_model,
get_labels,
set_parameters,
)
from .variance import variance_estimate, weighting_residual
# use logger for entire module
logger = logging.getLogger("impedancefitter")
def set_logger(level=logging.INFO):
"""Set logging level."""
logger.setLevel(level)
# to avoid multiple output in Jupyter notebooks
if len(logger.handlers) == 1:
ch = logging.StreamHandler()
ch.setLevel(level)
logger.addHandler(ch)
else:
for handler in logger.handlers:
if isinstance(handler, logging.StreamHandler):
handler.setLevel(level)
[docs]
class Fitter:
"""
The main fitting object class.
All files in the data directory with matching file ending are imported
to be fitted.
Parameters
----------
inputformat: string
The inputformat of the data files. Must be one of the formats specified
in :func:`impedancefitter.utils.available_file_format`.
Moreover, the ending of the file must match the `inputformat`.
directory: string, optional
Path to data directory.
Provide the data directory if the data directory is not the
current working directory.
LogLevel: {'DEBUG', 'INFO', 'WARNING'}, optional
choose level for logger. Case DEBUG: the script will output plots after
each fit, case INFO: the script will output results from each fit
to the console.
excludeEnding: string, optional
For file ending that should be ignored (if there are files with the
same ending as the chosen inputformat).
Useful for instance, if there are files like `*_data.csv` and
`*_result.csv` around and only the first should
be fitted.
excludeFilter: string, optional
Files containing a certain string are ignored (if they are files with the
same ending as the chosen inputformat).
Useful for instance, if there are files that were measured using
different measurement voltages. For example, files containing `_25mV`
should not be fitted.
ending: string, optional
When `inputformat` is `TXT`, an alternative file ending is possible.
The default is `.TXT`.
minimumFrequency: float, optional
If you want to use another frequency than the minimum frequency
in the dataset.
maximumFrequency: float, optional
If you want to use another frequency than the maximum frequency
in the dataset.
data_sets: int, optional
Use only a certain number of data sets instead of all in directory.
current_threshold: float, optional
Use only for data from E4980AL LCR meter to check current.
If the current is not close to the threshold,
the data point will be neglected.
voltage_threshold: float, optional
Use only for data from E4980AL LCR meter to check voltage.
If the voltage is not close to the threshold,
the data point will be neglected.
E4980AL_tolerance: float, optional
Set tolerance for `current_threshold` and/or `voltage_threshold`.
write_output: bool, optional
Decide if you want to dump output to file. Default is False
fileList: list of strings, optional
provide a list of files that exclusively should be processed.
No other files will be processed.
This option is particularly good if you have a common
fileending of your data (e.g., `.csv`)
show: bool, optional
Decide if you want to see the plots during the fitting procedure.
savefig: bool, optional
Decide if you want to save the plots. Default is False.
trace_b: string, optional
For TXT files, which contain more than one trace.
The data is only read in until
:attr:`trace_b` is found.
Default is None (then trace_b does not have any effect).
skiprows_txt: int, optional
Number of header rows inside a TXT file. Default is 1.
skiprows_trace: int, optional
Lines between traces blocks in a TXT file.
Default is None (then skiprows_trace does not have any effect).
delimiter: str, optional
Only for TXT and CSV files. If the TXT/CSV file is not tab-separated, this
can be specified here.
Attributes
----------
omega_dict: dict
Contains frequency lists that were found in the individual files.
The keys are the file names, the values the frequencies.
Z_dict: dict
Contains corresponding impedances. Note that the values might
be lists when there was more than one impedance data set in the file.
fit_data: dict
Contains the fitting results for each individual file.
fittedValues: :class:`lmfit.model.ModelResult`
The fitting result of the last data set that was fitted.
Exists only when :meth:`run` was called.
"""
def __init__(self, inputformat, directory=None, **kwargs):
"""Initializes the Fitter object."""
self.inputformat = inputformat
self._parse_kwargs(kwargs)
self.omega_dict = {}
self.z_dict = {}
if inputformat == "DF":
omega, zarray = self._read_dataframe()
self.omega_dict["dataframe"] = omega
self.z_dict["dataframe"] = zarray
return
if directory is None:
directory = os.getcwd()
self.directory = directory + "/"
set_logger(self.LogLevel)
# read in all data and store it
if self.fileList is None:
self.fileList = sorted(os.listdir(self.directory))
read_data_sets = 0
for filename in self.fileList:
if self.data_sets:
if read_data_sets == self.data_sets:
logger.debug("Reached maximum number of data sets.")
break
filename = os.fsdecode(filename)
if filename.endswith(self.excludeEnding):
logger.info(
f"""Skipped file {filename}
due to excluded ending."""
)
continue
if self.excludeFilter in filename and self.excludeFilter != "":
logger.info(
f"""Skipped file {filename}
due to excluded substring."""
)
continue
# continues only if data could be read in
(status, omega, zarray) = self._read_data(filename)
if status:
self.omega_dict[str(filename)] = omega
self.z_dict[str(filename)] = zarray
read_data_sets += 1
# ruff: noqa: C901
def _parse_kwargs(self, kwargs):
"""Parses the different kwargs when the Fitter object
is initialized.
"""
# set defaults
self.minimumFrequency = None
self.maximumFrequency = None
self.LogLevel = "INFO"
self.excludeEnding = "impossibleEndingLoL"
self.excludeFilter = ""
self.ending = ".TXT"
self.data_sets = None
self.current_threshold = None
self.voltage_threshold = None
self.E4980AL_tolerance = 1e-2
self.write_output = False
self.fileList = None
self.savefig = False
if self.inputformat == "TXT":
self.delimiter = "\t"
elif self.inputformat == "CSV":
self.delimiter = None
self.emcee_tag = False
self.solvername = "least_squares"
self.log = False
self.eps = False
self.weighting_model = False
self.solver_kwargs = {}
self.weighting = None
self.show = False
self.savemodelresult = True
self.report = False
# for csv files
self.skiprows_csv = 0 # header rows inside the *.csv files
# for txt files
self.trace_b = None
self.skiprows_txt = 1 # header rows inside the *.txt files
self.skiprows_trace = None # line between traces blocks
# for dataframe
self.df = None
self.df_freq_column = None
self.df_real_column = None
self.df_imag_column = None
# read in kwargs to update defaults
if "LogLevel" in kwargs:
self.LogLevel = kwargs["LogLevel"]
if "minimumFrequency" in kwargs:
self.minimumFrequency = float(kwargs["minimumFrequency"])
if "maximumFrequency" in kwargs:
self.maximumFrequency = float(kwargs["maximumFrequency"])
if "excludeEnding" in kwargs:
self.excludeEnding = str(kwargs["excludeEnding"])
if "excludeFilter" in kwargs:
self.excludeFilter = str(kwargs["excludeFilter"])
if "ending" in kwargs:
self.ending = str(kwargs["ending"])
if "data_sets" in kwargs:
self.data_sets = int(kwargs["data_sets"])
if "current_threshold" in kwargs:
self.current_threshold = float(kwargs["current_threshold"])
if "voltage_threshold" in kwargs:
self.voltage_threshold = float(kwargs["voltage_threshold"])
if "E4980AL_tolerance" in kwargs:
self.E4980AL_tolerance = float(kwargs["E4980AL_tolerance"])
if "write_output" in kwargs:
self.write_output = bool(kwargs["write_output"])
if "fileList" in kwargs:
self.fileList = kwargs["fileList"]
if "trace_b" in kwargs:
self.trace_b = kwargs["trace_b"]
if "skiprows_txt" in kwargs:
self.skiprows_txt = int(kwargs["skiprows_txt"])
if "skiprows_csv" in kwargs:
self.skiprows_csv = int(kwargs["skiprows_csv"])
if "skiprows_trace" in kwargs:
self.skiprows_trace = int(kwargs["skiprows_trace"])
if "show" in kwargs:
self.show = bool(kwargs["show"])
if "savefig" in kwargs:
self.savefig = bool(kwargs["savefig"])
if "delimiter" in kwargs:
self.delimiter = str(kwargs["delimiter"])
if "df" in kwargs:
self.df = kwargs["df"]
if "df_freq_column" in kwargs:
self.df_freq_column = str(kwargs["df_freq_column"])
if "df_real_column" in kwargs:
self.df_real_column = str(kwargs["df_real_column"])
if "df_imag_column" in kwargs:
self.df_imag_column = str(kwargs["df_imag_column"])
[docs]
def visualize_data(
self,
savefig=False,
Zlog=False,
allinone=False,
plottype="impedance",
show=True,
legend=True,
):
"""Visualize impedance data.
Parameters
----------
savefig: bool, optional
Decide if plots should be saved as pdf. Default is False.
Saves the file as `filename` + index of dataset + `_impedance_overview.pdf`.
If `allinone` is True, then it is `allinone_impedance_overview.pdf`.
Zlog: bool, optional
Plot impedance on logscale.
show: bool, optional
Decide if you want to immediately see the plot.
allinone: bool, optional
Visualize all data sets in one plot
plottype: str, optional
Choose between standard impedance plot ('impedance'),
resistance / capacitance ("RC") and bode plot ('bode').
legend: str, optional
Choose if a legend should be shown. Recommended to switch to False
when using large datasets.
"""
if not savefig and not show:
logger.warning(
"""visualize_data does not have any effect if you
neither save nor show the plot."""
)
return
totaliters = 0
savefigtmp = savefig
showtmp = show
labels = ["Data", None, None]
for key in self.z_dict:
zarray = self.z_dict[key]
totaliters += zarray.shape[0]
for key in self.omega_dict:
append = False
zarray = self.z_dict[key]
iters = zarray.shape[0]
logger.debug(f"Number of data sets in file {key} to visualise: {iters}")
for i in range(iters):
title = key + str(i)
if allinone and totaliters > 1:
append = True
savefigtmp = False
labels = [key + str(i), None, None]
showtmp = False
elif allinone and totaliters == 1:
append = False
labels = [key + str(i), None, None]
savefigtmp = savefig
title = "allinone"
showtmp = show
if plottype == "impedance":
plot_impedance(
self.omega_dict[key],
zarray[i],
title=title,
show=showtmp,
save=savefigtmp,
Zlog=Zlog,
append=append,
labels=labels,
legend=legend,
)
elif plottype == "bode":
plot_bode(
self.omega_dict[key],
zarray[i],
title=title,
show=showtmp,
save=savefigtmp,
append=append,
labels=labels,
legend=legend,
)
elif plottype == "RC":
plot_resistance_capacitance(
self.omega_dict[key],
zarray[i],
title=title,
show=showtmp,
save=savefigtmp,
append=append,
labels=labels,
legend=legend,
)
else:
raise RuntimeError("You chose an invalid plottype")
totaliters -= 1
def _initialize_parameters(
self, model, parameters, emcee=False, weighting_model=False
):
"""
The `model_parameters` are initialized either based on a
provided `parameterdict` or an input file.
Parameters
----------
model: string
Model name
parameters: dict
Parameter dictionary provided by user.
emcee: bool
Communicate if emcee was used.
weighting_model: str
Use an error model as weighting function.
See Also
--------
:func:`impedancefitter.utils.set_parameters`
:func:`impedancefitter.variance.weighting_residual`
"""
return set_parameters(
model,
parameterdict=parameters,
emcee=emcee,
weighting_model=weighting_model,
)
[docs]
def initialize_model(self, modelname, log=False, eps=False):
"""Interface to LMFIT model class.
The equivalent circuit (represented as a string)
is parsed and a LMFIT Model is returned.
This can be useful if one wants to compute the impedance values
for a given model and use it in a different context.
Parameters
----------
modelname: string
Provide equivalent circuit model to be parsed.
log: bool
Decide, if logscale is used for the model.
eps: bool
Convert to complex permittivity and fit this instead of impedance.
Returns
-------
model: :class:`lmfit.model.Model`
The resulting LMFIT model.
"""
model = get_equivalent_circuit_model(modelname, log, eps)
return model
# ruff: noqa: C901
[docs]
def run(
self,
modelname,
solver=None,
parameters=None,
model_kwargs={},
solver_kwargs={},
log=False,
weighting=None,
show=False,
report=False,
savemodelresult=True,
eps=False,
residual="parts",
limits_residual=None,
weighting_model=False,
):
"""
Main function that iterates through all data sets provided.
Parameters
----------
modelname: string
Name of the model to be parsed. Must be built by those provided in
:func:`impedancefitter.utils.available_models` and using `+`
and `parallel(x, y)` as possible representations of series or
parallel circuit.
solver: string, optional
Choose an optimizer. Must be available in LMFIT.
Default is least_squares
parameters: dict, optional
Provide parameters if you do not want
to read them from a yaml file (for instance in parallel UQ runs).
model_kwargs: dict, optional
Provide additional keywords for the model function to be called
in the fitting routine.
solver_kwargs: dict, optional
Customize the employed solver. Interface to the LMFIT routine.
weighting: str, optional
Choose a weighting scheme. Default is unit weighting.
Also possible: proportional and modulus weighting.
See [Barsoukov2018]_ and [Orazem2017]_ for more information.
Moreover, weighted least squares is possible (with `weighting_model`).
weighting_model: bool, optional
Uses weighted least squares.
This should only be chosen if the data can be averaged.
The average data will be fitted and
the standard deviation is used for the weights.
The keyword for this is WLS. In this case, you need to provide weights.
savemodelresult: bool, optional
Saves all :class:`lmfit.model.ModelResult` instances to plot
or evaluate uncertainty later.
residual: str
Plot relative difference w.r.t. real and imaginary part if `parts`.
Plot relative difference w.r.t. absolute value if `absolute`.
Plot difference (residual) if `diff`.
limits_residual: list, optional
List with entries `[bottom, top]` for y-axis of residual plot.
eps: bool, optional
Fit dielectric properties instead of impedance
report: bool, optional
print report
show: bool, optional
Show plots
log: bool, optional
Log-transform impedance for fitting
"""
self.modelname = modelname
self.log = log
self.eps = eps
self.weighting_model = weighting_model
if weighting is not None:
if isinstance(weighting, str) and weighting in [
"proportional",
"modulus",
"reweighting",
"WLS",
]:
self.weighting = weighting
elif self.weighting_model:
raise RuntimeError(
"The variable `weighting` must be None if you want "
"to use the weighting_model. "
"Otherwise, you must set weighting_model to False."
)
else:
raise RuntimeError(
"The variable `weighting` must be a string and "
"refer to an available weighting scheme. "
"Use either `proportional` or `modulus` or `WLS`."
)
else:
self.weighting = weighting
self.show = show
self.report = report
# initialize solver
if solver is not None:
self.solvername = solver
else:
self.solvername = "least_squares"
SCALAR_METHODS = [
"nelder",
"Nelder-Mead",
"powell",
"Powell",
"cg",
"CG",
"bfgs",
"BFGS",
"newton",
"Newton-CG",
"lbfgsb",
"L-BFGS-B",
"tnc",
"TNC",
"cobyla",
"COBYLA",
"slsqp",
"SLSQP",
"dogleg",
"trust-ncg",
"differential_evolution",
"trust-constr",
"trust-exact",
"trust-krylov",
]
if weighting_model and self.solvername not in SCALAR_METHODS:
raise AttributeError(f"Provide a scalar method. They are {SCALAR_METHODS}")
if self.solvername == "emcee":
self.emcee_tag = True
else:
self.emcee_tag = False
self.solver_kwargs = solver_kwargs
# initialize model
self.model = self.initialize_model(self.modelname, log=self.log, eps=self.eps)
# initialize model parameters
if parameters is not None:
logger.debug("Using provided parameter dictionary.")
if not isinstance(parameters, dict):
raise ValueError("You need to provide an input dictionary")
self.parameters = deepcopy(parameters)
self.model_parameters = self._initialize_parameters(
self.model, self.parameters, self.emcee_tag, self.weighting_model
)
if self.write_output is True:
open("outfile.yaml", "w") # create output file
# TODO:
# implement WLS here
for key in self.omega_dict:
self.omega = self.omega_dict[key]
self.zarray = self.z_dict[key]
self.iters = 1
# determine number of iterations if more than 1 data set is in file
if len(self.zarray.shape) > 1:
self.iters = self.zarray.shape[0]
logger.debug("Number of data sets:" + str(self.iters))
if self.data_sets is not None:
self.iters = self.data_sets
logger.debug(
f"""Will only iterate
over {self.iters} data sets."""
)
for i in range(self.iters):
self.Z = self.zarray[i]
self.fittedValues = self.process_data_from_file(
key + str(i),
self.model,
self.model_parameters,
residual,
limits_residual,
self.weighting_model,
model_kwargs,
)
self._process_fitting_results(key + "_" + str(i))
if self.write_output is True and hasattr(self, "fit_data"):
outfile = open("outfile.yaml", "w")
yaml.dump(self.fit_data, outfile)
elif not hasattr(self, "fit_data"):
logger.info("There was no file to process")
def _read_dataframe(self):
"""Read in data from dataframe."""
if (
self.df is None
or self.df_freq_column is None
or self.df_real_column is None
or self.df_imag_column is None
):
raise ValueError(
"You need to provide a dataframe and the columns"
" for frequency, real and imaginary part."
)
omega, zarray = readin_Data_from_dataframe(
df=self.df,
df_freq_column=self.df_freq_column,
df_real_column=self.df_real_column,
df_imag_column=self.df_imag_column,
minimumFrequency=self.minimumFrequency,
maximumFrequency=self.maximumFrequency,
)
if zarray.size == 0:
raise RuntimeError("In the dataframe an empty data set was provided.")
return omega, zarray
def _read_data(self, filename):
"""Read in data from file.
Parameters
----------
filename: str
Name of the file to read from.
"""
filepath = self.directory + filename
if self.inputformat == "TXT" and filename.upper().endswith(self.ending.upper()):
omega, zarray = readin_Data_from_TXT_file(
filepath,
self.skiprows_txt,
self.skiprows_trace,
self.trace_b,
self.delimiter,
self.minimumFrequency,
self.maximumFrequency,
)
elif self.inputformat == "DTA" and filename.upper().endswith(".DTA"):
omega, zarray = readin_Data_from_dta(
filepath,
minimumFrequency=self.minimumFrequency,
maximumFrequency=self.maximumFrequency,
)
elif self.inputformat == "XLSX" and filename.upper().endswith(".XLSX"):
omega, zarray = readin_Data_from_collection(
filepath,
"XLSX",
minimumFrequency=self.minimumFrequency,
maximumFrequency=self.maximumFrequency,
)
elif self.inputformat == "CSV" and filename.upper().endswith(".CSV"):
omega, zarray = readin_Data_from_collection(
filepath,
"CSV",
delimiter=self.delimiter,
minimumFrequency=self.minimumFrequency,
maximumFrequency=self.maximumFrequency,
header=self.skiprows_csv,
)
elif self.inputformat == "CSV_E4980AL" and filename.endswith(".csv"):
omega, zarray = readin_Data_from_csv_E4980AL(
filepath,
minimumFrequency=self.minimumFrequency,
maximumFrequency=self.maximumFrequency,
current_threshold=self.current_threshold,
voltage_threshold=self.voltage_threshold,
tolerance=self.E4980AL_tolerance,
)
else:
return False, None, None
if zarray.size == 0:
raise RuntimeError(
f"In file {filename} an empty data set was provided. "
"If you used the E4980AL and set a current_threshold, "
"the reason might be that there was no data point that matched "
"the current_threshold."
)
return True, omega, zarray
def _process_fitting_results(self, filename):
"""Write output to yaml file to prepare statistical analysis of the results.
Parameters
----------
filename: string
name of file that is used as key in the output dictionary.
"""
if not hasattr(self, "fit_data"):
self.fit_data = {}
values = self.fittedValues.best_values
for key in values:
if key == "Zdata":
continue
# conversion into python native type
values[key] = float(values[key])
self.fit_data[str(filename)] = values
if self.savemodelresult:
if not hasattr(self, "model_results"):
self.model_results = {}
self.model_results[str(filename)] = self.fittedValues
def _fit_data(
self,
model,
parameters,
weights=None,
log=True,
eps=False,
weighting_model=False,
model_kwargs={},
):
"""Fit data to model.
Wrapper for LMFIT fitting routine.
Parameters
----------
model: :class:`lmfit.model.Model` or :class:`lmfit.model.CompositeModel`
The model to fit to.
parameters: :class:`lmfit.parameter.Parameters`
The model parameters to be used.
weights: None or :class:`numpy.ndarray`
Use weights to fit the data. Default is None (no weighting).
The weights need to have the same shape as the impedance data.
weighting_model: bool, optional
Uses weighted least squares.
This should only be chosen if the data can be averaged.
The average data will be fitted and
the standard deviation is used for the weights.
The keyword for this is WLS. In this case, you need to provide weights.
eps: bool, optional
Fit dielectric properties instead of impedance
log: bool, optional
Log-transform impedance for fitting
model_kwargs: dict, optional
Pass keyword arguments to equivalent circuit model function
Returns
-------
:class:`lmfit.model.ModelResult`
Result of fit as LMFIT.ModelResult object.
"""
logger.debug("#################################")
logger.debug(f"fit data to {model._name} model")
logger.debug("#################################")
# initiate copy of parameters
# TODO: still needed?
params = deepcopy(parameters)
# initiate empty result
model_result = None
logger.debug("#########\nFitting started \n#########")
if log:
Z = np.log10(self.Z)
elif eps:
Z = 1.0 / (1j * self.omega * params["c0all"] * self.Z)
else:
Z = self.Z
max_nfev = None
if "max_nfev" in self.solver_kwargs:
max_nfev = self.solver_kwargs["max_nfev"]
tmp_dict = {
key: self.solver_kwargs[key]
for key in set(self.solver_kwargs.keys()) - {"max_nfev"}
}
else:
tmp_dict = self.solver_kwargs
if weighting_model:
model_result = lmfit.minimize(
weighting_residual,
params,
method=self.solvername,
args=(self.omega,),
kws={"Zdata": Z, "model": model, "model_kwargs": model_kwargs},
)
best_values = deepcopy(model_result.params.valuesdict())
setattr(model_result, "best_values", best_values)
else:
# this is also k=0 in reweighting
model_result = model.fit(
Z,
params,
omega=self.omega,
method=self.solvername,
fit_kws=tmp_dict,
weights=weights,
max_nfev=max_nfev,
**model_kwargs,
)
if self.weighting == "reweighting":
# raise NotImplementedError("Not yet implemented")
tol = 1e-7
tolA = 1
tolPhi = 1
tolZ = 1
varA_old = 0
varPhi_old = 0
kmax = 50000
k = 0
while (
np.greater_equal(tolA, tol)
or np.greater_equal(tolZ, tol)
or np.greater_equal(tolPhi, tol)
) and k < kmax:
if k == 0:
Zfit = model_result.best_fit
varA, varPhi = variance_estimate(Z, Zfit)
fitparams = np.fromiter(
model_result.params.values(), dtype=float
)
varA_old, varPhi_old = varA, varPhi
fitparamsold = fitparams
weights = 1 / (np.abs(Z) ** 2 * varA_old) + 1j / (
np.abs(Z) ** 2 * (varA_old + varPhi_old) * np.angle(Z) ** 2
)
model_result = model.fit(
Z,
params,
omega=self.omega,
method=self.solvername,
fit_kws=tmp_dict,
weights=weights,
max_nfev=max_nfev,
)
Zfit = model_result.best_fit
params = model_result.params
fitparams = np.fromiter(model_result.params.values(), dtype=float)
varA, varPhi = variance_estimate(Z, Zfit)
tolA = np.abs(varA_old - varA) / varA
tolPhi = np.abs(varPhi_old - varPhi) / varPhi
diff = fitparamsold - fitparams
tolZ = np.sqrt(diff.dot(diff)) / np.sqrt(fitparams.dot(fitparams))
print(tolA, tolPhi, tolZ)
k += 1
logger.info(f"Converged after {k} iterations")
if self.weighting in ["proportional", "modulus", "WLS"]:
_calculate_statistics(model_result)
if not self.report:
if weighting_model:
logger.debug(lmfit.fit_report(model_result))
else:
logger.debug(model_result.fit_report())
else:
if weighting_model:
logger.info(lmfit.fit_report(model_result))
else:
logger.info(model_result.fit_report())
# return solver message (needed since lmfit handles messages
# differently for the various solvers)
if hasattr(model_result, "message"):
if model_result.message is not None:
logger.debug("Solver message: " + model_result.message)
if hasattr(model_result, "lmdif_message"):
if model_result.lmdif_message is not None:
logger.debug("Solver message (leastsq): " + model_result.lmdif_message)
if hasattr(model_result, "ampgo_msg"):
if model_result.ampgo_msg is not None:
logger.debug("Solver message (ampgo): " + model_result.ampgo_msg)
return model_result
[docs]
def get_resistance_capacitance(self):
"""Convert impedance to resistance and capacitance."""
self.R_dict = {}
self.C_dict = {}
for key in self.omega_dict:
self.omega = self.omega_dict[key]
self.zarray = self.z_dict[key]
rarray = np.zeros(self.zarray.shape, dtype=np.float128)
carray = np.zeros(self.zarray.shape, dtype=np.float128)
self.iters = 1
# determine number of iterations if more than 1 data set is in file
if len(self.zarray.shape) > 1:
self.iters = self.zarray.shape[0]
logger.debug("Number of data sets:" + str(self.iters))
for i in range(self.iters):
self.Z = self.zarray[i]
rarray[i], carray[i] = _return_resistance_capacitance(
self.omega, self.Z
)
self.R_dict[key] = rarray
self.C_dict[key] = carray
[docs]
def get_admittance(self):
"""Convert impedance to admittance."""
self.Y_dict = {}
for key in self.omega_dict:
self.zarray = self.z_dict[key]
xarray = np.zeros(self.zarray.shape, dtype=np.complex128)
self.iters = 1
# determine number of iterations if more than 1 data set is in file
if len(self.zarray.shape) > 1:
self.iters = self.zarray.shape[0]
logger.debug("Number of data sets:" + str(self.iters))
for i in range(self.iters):
self.Z = self.zarray[i]
xarray[i] = 1.0 / self.Z
self.Y_dict[key] = xarray
[docs]
def process_data_from_file(
self,
filename,
model,
parameters,
residual="parts",
limits_residual=None,
weighting_model=False,
model_kwargs={},
):
"""Fit data from input file to model.
Wrapper for LMFIT fitting routine.
Parameters
----------
filename: str
Filename, which is contained in the data dictionaries
:attr:`omega_dict` and :attr:`z_dict`.
model: :class:`lmfit.model.Model` or :class:`lmfit.model.CompositeModel`
The model to fit to.
parameters: :class:`lmfit.parameter.Parameters`
The model parameters to be used.
residual: str
Plot relative difference w.r.t. real and imaginary part if `parts`.
Plot relative difference w.r.t. absolute value if `absolute`.
Plot difference (residual) if `diff`.
limits_residual: list, optional
List with entries `[bottom, top]` for y-axis of residual plot.
model_kwargs: dict, optional
Provide additional keywords for the model function to be called
in the fitting routine.
weighting_model: str
Use an error model as weighting function.
Returns
-------
:class:`lmfit.model.ModelResult`
Result of fit as :class:`lmfit.model.ModelResult` object.
"""
logger.debug("Going to fit")
weights = None
if self.weighting == "proportional":
weights = 1.0 / self.Z.real + 1j / self.Z.imag
elif self.weighting == "modulus":
weights = 1.0 / np.abs(self.Z) + 1j / np.abs(self.Z)
elif self.weighting == "WLS":
weights = self.Zstd
fit_output = self._fit_data(
model,
parameters,
log=self.log,
model_kwargs=model_kwargs,
eps=self.eps,
weights=weights,
weighting_model=weighting_model,
)
if self.log:
Z_fit = np.power(10, fit_output.best_fit)
elif self.eps:
Z_fit = 1.0 / (1j * self.omega * fit_output.best_fit * parameters["c0all"])
elif self.weighting_model:
tmp_model = get_equivalent_circuit_model(self.modelname)
Z_fit = tmp_model.eval(omega=self.omega, **fit_output.best_values)
else:
Z_fit = fit_output.best_fit
logger.debug("Fit successful")
# plots if LogLevel is DEBUG or figure should be saved
if self.show or self.savefig:
if self.show:
logger.info("Going to plot results")
if self.savefig:
logger.info("Going to save plot of fit result to file.")
title = "fit_result_" + filename
plot_impedance(
self.omega,
self.Z,
title=title,
Z_fit=Z_fit,
show=self.show,
save=self.savefig,
residual=residual,
limits_residual=limits_residual,
)
return fit_output
[docs]
def plot_initial_best_fit(self, save=False, show=True):
"""Plot initial and best fit together.
This method reveals how good the initial fit was.
Parameters
----------
save: bool, optional
Save plot
show: bool, optional
Show plot
"""
if self.log:
Z_fit = np.power(10, self.fittedValues.best_fit)
Z_init = np.power(10, self.fittedValues.init_fit)
else:
Z_fit = self.fittedValues.best_fit
Z_init = self.fittedValues.init_fit
plot_impedance(
self.omega,
self.Z,
"Comparison of initial and best fit",
Z_fit=Z_fit,
show=show,
save=save,
Z_comp=Z_init,
)
[docs]
def cluster_emcee_result(self, constant=1e2, show=False):
r"""Apply clustering to eliminate low-probability samples.
Parameters
----------
constant: float
The constant, which is used to define the threshold
from which on walkers are eliminated.
show: bool, optional
Plot clustering result.
Notes
-----
The clustering approach described in [Hou2012]_ is implemented in
this function.
The walkers are sorted by probability and subsequently
the difference between adjacent walker probabilities
:math:`\Delta_j` is evaluated.
Then the average difference between the current and the first
walkeri (:math:`\bar{\Delta}_j`) is evaluated.
Both differences are compared and a threshold is defined:
.. math::
\Delta_j > \mathrm{constant} \cdot \bar{\Delta}_j
When this inequality becomes true,
all walkers with :math:`k > j` are thrown away.
References
----------
.. [Hou2012] Hou, F., Goodman, J., Hogg, D. W., Weare, J., & Schwab, C. (2012).
An affine-invariant sampler for exoplanet fitting and
discovery in radial velocity data. Astrophysical Journal, 745(2).
https://doi.org/10.1088/0004-637X/745/2/198
"""
if not hasattr(self, "model_results"):
raise ValueError("You need to have saved the LMFIT model results.")
if not self.emcee_tag:
raise ValueError(
"You need to have run emcee as a solver to use this function"
)
for fits in self.model_results:
res = self.model_results[fits]
lnprob = np.swapaxes(res.lnprob, 0, 1)
chain = np.swapaxes(res.chain, 0, 1)
walker_prob = []
for i in range(lnprob.shape[0]):
walker_prob.append(-np.mean(lnprob[i]))
if show:
plt.title("walkers sorted by probability")
plt.xlabel("walker")
plt.ylabel("negative mean ln probability")
plt.plot(np.sort(walker_prob))
plt.show()
sorted_indices = np.argsort(walker_prob)
sorted_fields = np.sort(walker_prob)
l0 = sorted_fields[0]
differences = np.diff(sorted_fields)
if show:
plt.title("difference between adjacent walkers")
plt.xlabel("walker")
plt.ylabel("difference")
plt.plot(differences)
plt.show()
# numerator with j + 1 since enumerate starts from 0
average_differences = [
(x - l0) / (j + 1) for j, x in enumerate(sorted_fields[1::])
]
if show:
plt.title("average difference between current and first walker")
plt.ylabel("average difference")
plt.xlabel("walker")
plt.plot(average_differences)
plt.show()
# set cut to the maximum number of walkers
cut = len(walker_prob)
for i in range(differences.size):
if differences[i] > constant * average_differences[i]:
cut = i
logger.debug(f"Cut off at walker {cut}")
break
if show:
plt.title("Acceptance fractions after clustering")
plt.xlabel("walker")
plt.ylabel("acceptance fraction")
plt.plot(
np.take(res.acceptance_fraction, sorted_indices[::]),
label="initial",
)
plt.plot(
np.take(res.acceptance_fraction, sorted_indices[:cut:]),
label="clustered",
)
plt.legend()
plt.show()
setattr(res, "new_chain", np.take(chain, sorted_indices[:cut:], axis=0))
setattr(
res,
"new_flatchain",
pd.DataFrame(
res.new_chain.reshape((-1, res.nvarys)), columns=res.var_names
),
)
[docs]
def emcee_report(self):
"""Reports acceptance fraction and autocorrelation times."""
if not self.emcee_tag:
logger.error(
"""You need to have run emcee
as a solver to use this function"""
)
return
if not hasattr(self, "model_results"):
raise ValueError("You need to have saved the LMFIT model results.")
for fits in self.model_results:
fittedValues = self.model_results[fits]
if hasattr(fittedValues, "acor"):
i = 0
logger.info("Correlation times per parameter")
for p in fittedValues.params:
if fittedValues.params[p].vary:
print(p, fittedValues.acor[i])
i += 1
else:
logger.warning(
"""No autocorrelation data available.
Maybe run a longer chain?"""
)
plt.xlabel("walker")
plt.ylabel("acceptance fraction")
plt.plot(fittedValues.acceptance_fraction)
plt.show()
highest_prob = np.argmax(fittedValues.lnprob)
hp_loc = np.unravel_index(highest_prob, fittedValues.lnprob.shape)
mle_soln = fittedValues.chain[hp_loc]
paramsmle = {}
for i, par in enumerate(fittedValues.var_names):
paramsmle[par] = mle_soln[i]
print("\nMaximum Likelihood Estimation from emcee ")
print("-------------------------------------------------")
print("Parameter MLE Value Median Value Uncertainty")
fmt = " {:5s} {:11.5f} {:11.5f} {:11.5f}".format
for name in fittedValues.var_names:
print(
fmt(
name,
paramsmle[name],
fittedValues.params[name].value,
fittedValues.params[name].stderr,
)
)
[docs]
def plot_emcee_chains(self, title=True, savefig=False):
"""Plot chains of MCMC run in emcee."""
for fits in self.model_results:
fittedValues = self.model_results[fits]
ndim = len(fittedValues.var_names)
fig, axes = plt.subplots(ndim, figsize=(15, 10), sharex=True)
samples = fittedValues.chain
labels = get_labels(fittedValues.var_names)
for i in range(ndim):
ax = axes[i]
ax.plot(samples[:, :, i], "k", alpha=0.3)
ax.set_xlim(0, len(samples))
ax.set_ylabel(labels[fittedValues.var_names[i]])
ax.yaxis.set_label_coords(-0.1, 0.5)
axes[-1].set_xlabel("step number")
plt.suptitle("Chains " + fits, y=1.05)
plt.tight_layout()
if savefig:
plt.savefig(fits + "__chains.pdf")
plt.show()
[docs]
def plot_uncertainty_interval(self, sigma=1):
"""Plot uncertainty interval around best fit.
Parameters
----------
sigma: {1, 2, 3}, optional
Choose sigma for confidence interval.
"""
if not isinstance(sigma, int):
raise ValueError("Sigma needs to be integer and range between 1 and 3.")
if not 1 <= sigma <= 3:
raise ValueError("Sigma needs to be integer and range between 1 and 3.")
if not hasattr(self, "model_results"):
raise ValueError("You need to have saved the LMFIT model results.")
for d in self.fit_data:
iters = 1
for i in range(iters):
modelresult = self.model_results[d]
if self.solvername != "emcee":
logger.debug("Not Using emcee")
ci = modelresult.conf_interval()
else:
logger.debug("Using emcee")
ci = self.emcee_conf_interval(modelresult)
eval1 = lmfit.Parameters()
eval2 = lmfit.Parameters()
for p in modelresult.params:
if p == "__lnsigma":
continue
eval1.add(p, value=modelresult.best_values[p])
eval2.add(p, value=modelresult.best_values[p])
if p in ci:
eval1[p].set(value=ci[p][3 - sigma][1])
eval2[p].set(value=ci[p][3 + sigma][1])
Z1 = modelresult.eval(params=eval1)
Z2 = modelresult.eval(params=eval2)
Z = modelresult.best_fit
plot_uncertainty(
modelresult.userkws["omega"],
modelresult.data,
Z,
Z1,
Z2,
sigma,
model=i,
)
[docs]
def emcee_conf_interval(self, result):
r"""Compute emcee confidence intervals.
The :math:`1\sigma` to :math:`3\sigma` confidence
intervals are computed for a fitting result
generated by emcee since this case is not
covered by the original LMFIT implementation.
Parameters
----------
result: :class:`lmfit.model.ModelResult`
Result from fit.
Returns
-------
dict
Dictionary containing limits of confidence intervals
for all free parameters.
The limits are structured in a list with 7 items, which
are ordered as follows:
#. lower limit of :math:`3\sigma` confidence interval.
#. lower limit of :math:`2\sigma` confidence interval.
#. lower limit of :math:`1\sigma` confidence interval.
#. median.
#. upper limit of :math:`1\sigma` confidence interval.
#. upper limit of :math:`2\sigma` confidence interval.
#. upper limit of :math:`3\sigma` confidence interval.
"""
if not self.emcee_tag:
print(
"""You need to have run emcee
as a solver to use this function"""
)
return
ci = {}
percentiles = [
0.5 * (1.0 - 0.9973002039367398),
0.5 * (1.0 - 0.9544997361036416),
0.5 * (1.0 - 0.6826894921370859),
0.5,
0.5 * (1.0 + 0.6826894921370859),
0.5 * (1.0 + 0.9544997361036416),
0.5 * (1.0 + 0.9973002039367398),
]
pars = [p for p in result.params if result.params[p].vary]
for i, p in enumerate(pars):
quantile = np.percentile(result.flatchain[p], np.array(percentiles) * 100)
ci[p] = list(zip(percentiles, quantile))
return ci
[docs]
def prepare_emcee_run(
self,
leastsquaresresult,
lnsigma={"value": np.log(0.1), "min": np.log(0.001), "max": np.log(2)},
nwalkers=100,
radius=1e-4,
weighted=False,
burn=500,
steps=10e3,
thin=10,
fix_parameters=[],
):
"""Prepare initial configuration based on previous least squares run.
Parameters
----------
leastsquaresresult: :class:`lmfit.ModelResult`
Result of previous least squares run on same model.
Basically the initial setting. Could also stem from
a previous MCMC run.
lnsigma: dict
Value of initial guess for experimental uncertainty.
nwalkers: int
Number of walkers.
radius: float
Radius of ball around initial guess.
burn: int
Number of steps to be removed from MCMC chain in burn-in phase.
steps: int
Length of MCMC chain.
thin: int
Take only every `thin`-th step into account.
weighted: bool
If `False`, `lnsigma` will be used.
fix_parameters: list
Tell emcee to fix certain parameters to their least squares result.
Notes
-----
The result from a previous least squares run is taking and
the emcee run is prepared.
The walkers are created and their initial positions are assigned.
The initial positions are placed in a tight Gaussian ball around
the least squares guess. Their radius can be set manually.
Important parameters for the MCMC chains are set.
Returns
-------
dict
dictionary, which can be passed to run method via `solver_kwargs` keyword.
"""
# take results from least squares
parameters_dict = {}
for ls_param in leastsquaresresult.params:
parameters_dict[ls_param] = {}
parameters_dict[ls_param]["value"] = leastsquaresresult.params[
ls_param
].value
parameters_dict[ls_param]["min"] = -np.inf
parameters_dict[ls_param]["max"] = np.inf
if ls_param in fix_parameters:
parameters_dict[ls_param]["vary"] = False
else:
parameters_dict[ls_param]["vary"] = leastsquaresresult.params[
ls_param
].vary
# get parameters in right order
parameters = []
for p in leastsquaresresult.var_names:
if parameters_dict[p]["vary"] is True:
parameters.append(p)
# for__lnsigma if lnsigma has not been fitted yet
if "__lnsigma" not in parameters:
parameters_dict["__lnsigma"] = lnsigma
ls_values = [parameters_dict[p]["value"] for p in parameters]
nvarys = len(parameters)
if "__lnsigma" not in parameters:
ls_values.append(lnsigma["value"])
nvarys += 1
# and assemble tiny gaussian ball around least squares result
pos = np.array(ls_values) * (1.0 + radius * np.random.randn(nwalkers, nvarys))
solver_kwargs = {
"seed": 42,
# 'nan_policy': 'omit',
"burn": burn,
"steps": steps,
"nwalkers": nwalkers,
"thin": thin,
"pos": pos,
"is_weighted": weighted,
}
return solver_kwargs, parameters_dict
[docs]
def linkk_test(
self,
capacitance=False,
inductance=False,
save=False,
c=0.85,
maxM=100,
show=True,
limits=[-2, 2],
weighting="modulus",
):
"""Lin-KK test to check Kramers-Kronig validity.
Parameters
----------
capacitance: bool, optional
Add extra capacitance to circuit.
inductance: bool, optional
Add extra inductance to circuit.
c: double, optional
Set threshold for algorithm as described in [Schoenleber2014]_.
Must be between 0 and 1.
maxM: int, optional
Maximum number of RC elements. Default is 100.
show: bool
Show plots of test result. Default is True.
limits: list, optional
Lower and upper limit of residual.
weighting: "modulus" or None
Apply modulus weighting (as in [Schoenleber2014]_) or process
unweighted data.
save: bool
Save figures of results
Returns
-------
results : dict
Values of Lin-KK test for each file.
mus: dict
All `mu` values during Lin-KK run for each file.
residuals: dict
Least-squares residuals during Lin-KK run for each file.
Notes
-----
The implementation of the algorithm follows [Schoenleber2014]_
closely.
If the option `savefig` is generally enabled, the plot result
of the LinKK-Test will be saved to a pdf-file.
References
----------
.. [Schoenleber2014] Schönleber, M., Klotz, D., & Ivers-Tiffée, E. (2014).
A Method for Improving the Robustness of
linear Kramers-Kronig Validity Tests.
Electrochimica Acta, 131, 20–27.
https://doi.org/10.1016/j.electacta.2014.01.034
"""
results = {}
mus = {}
residuals = {}
titlebegin = "Lin-KK test "
if save != self.savefig:
logger.warning(
"Kwarg `savefig' is overwriting the global `savefig` attribute."
)
save = self.savefig
if show != self.show:
logger.warning("Kwarg `show' is overwriting the global `show` attribute.")
if capacitance:
titlebegin += "capacitance "
if inductance:
titlebegin += "inductance "
for key in self.omega_dict:
self.omega = self.omega_dict[key]
self.zarray = self.z_dict[key]
if self.omega.size < maxM:
logger.warning(
"The maximal number of RC elements is greater than "
f"the number of frequencies ({self.omega.size}). "
"This does not make sense. "
"Consider decreasing the maximal number of RC elements if "
"the LinKK test uses more RC elements than frequencies."
)
# determine number of iterations if more than 1 data set is in file
if len(self.zarray.shape) > 1:
self.iters = self.zarray.shape[0]
logger.debug("Number of data sets:" + str(self.iters))
if self.data_sets is not None:
self.iters = self.data_sets
logger.debug(
f"""Will only iterate
over {self.iters} data sets."""
)
for i in range(self.iters):
self.Z = self.zarray[i]
(
results[key + str(i)],
mus[key + str(i)],
residuals[key + str(i)],
) = self._linkk_core(
self.omega,
self.Z,
capacitance,
inductance,
c,
maxM,
weighting=weighting,
)
if show or save:
Z_fit = self._get_linkk_impedance(results[key + str(i)])
plot_impedance(
self.omega,
self.Z,
title=titlebegin + str(key) + str(i),
Z_fit=Z_fit,
residual="absolute",
limits_residual=limits,
show=show,
save=save,
sign=True,
)
return results, mus, residuals
def _get_linkk_impedance(self, params):
"""Compute Lin-KK impedance.
Parameters
----------
params: dict
Parameter returned from Lin-KK test.
Returns
-------
:class:`numpy.ndarray`, complex
impedances
"""
modelR = "R"
R = self.initialize_model(modelR)
Z_fit = R.eval(omega=self.omega, R=params["R"])
start = 1
if "C" in params:
modelC = "C"
C = self.initialize_model(modelC)
Z_fit = np.sum([Z_fit, C.eval(omega=self.omega, C=params["C"])], axis=0)
start += 1
if "L" in params:
modelL = "L"
L = self.initialize_model(modelL)
Z_fit = np.sum([Z_fit, L.eval(omega=self.omega, L=params["L"])], axis=0)
start += 1
M = int((len(params) - start) / 2)
modelRC = "RCtau"
RC = self.initialize_model(modelRC)
RCtaus = np.array(
[
RC.eval(
omega=self.omega,
Rk=params["R_" + str(m)],
tauk=params["tau_" + str(m)],
)
for m in range(M)
]
)
if M > 1:
add = np.sum(RCtaus, axis=0)
Z_fit = np.sum([Z_fit, add], axis=0)
else:
Z_fit = np.sum([Z_fit, RCtaus[0]], axis=0)
return Z_fit
def _linkk_core(
self,
omega,
Z,
capacitance=False,
inductance=False,
c=0.85,
maxM=100,
weighting="modulus",
):
"""Core of Lin-KK algorithm.
Parameters
----------
omega: :class:`numpy.ndarray`, double
list of frequencies
Z: :class:`numpy.ndarray`, impedance
impedance array
capacitance: bool, optional
Add extra capacitance to circuit.
inductance: bool, optional
Add extra inductance to circuit.
c: double, optional
Set threshold for algorithm as described in [Schoenleber2014]_.
Must be between 0 and 1.
maxM: int, optional
Maximum number of RC elements. Default is 100.
weighting: "modulus" or None
Apply modulus weighting (as in [Schoenleber2014]_) or process
unweighted data.
Notes
-----
The implementation of the algorithm follows [Schoenleber2014]_
closely.
Returns
-------
fitresult : dict
Values of Lin-KK test.
mus: list
All `mu` values during Lin-KK run.
residuals: list
Least-squares residuals during Lin-KK run.
"""
# initial value for M
M = 1
tau_min = 1.0 / omega[-1]
tau_max = 1.0 / omega[0]
mu = 1.0
# initialize initial resistor
modelR = "R"
R = self.initialize_model(modelR)
# initialize matrix for linear least-squares
if weighting == "modulus":
weight = 1.0 / np.abs(self.Z)
elif weighting is None:
weight = np.ones(self.Z.size)
print(weight)
else:
raise RuntimeError("This is not a valid weighting option.")
weightM = np.diag(weight)
Abase = R.eval(omega=self.omega, R=1.0).real
start = 1
# add capacitance and inductance if specified
if capacitance:
modelC = "C"
C = self.initialize_model(modelC)
Abase = np.c_[Abase, C.eval(omega=self.omega, C=1.0)]
start += 1
if inductance:
modelL = "L"
L = self.initialize_model(modelL)
Abase = np.c_[Abase, L.eval(omega=self.omega, L=1.0)]
start += 1
mus = []
res = []
modelRC = "RCtau"
tauk = tau_max
RC = self.initialize_model(modelRC)
while np.greater(mu, c):
A = np.copy(Abase)
if M > 1:
for m in range(M):
tauk = np.power(
10,
np.log10(tau_min) + m / (M - 1) * np.log10(tau_max / tau_min),
)
A = np.c_[A, RC.eval(omega=self.omega, Rk=1.0, tauk=tauk)]
else:
A = np.c_[A, RC.eval(omega=self.omega, Rk=1.0, tauk=tauk)]
# solve least-squares problem for real and imaginary part together
real = weightM.dot(A.real)
imag = weightM.dot(A.imag)
Zweighted = weightM.dot(self.Z)
a = np.concatenate((real, imag))
y = np.concatenate((Zweighted.real, Zweighted.imag))
# note that rcond=-1 means that singular values are hardly reached
b, residualslstsq, rank, s = np.linalg.lstsq(a, y, rcond=-1)
rks = b[start:]
mu = _compute_mu(rks)
# when M is greater than the number of frequencies,
# the residual is not computed by numpy thus we put a -1 there
if len(residualslstsq) == 1:
res.append(residualslstsq[0])
else:
print(residualslstsq)
logger.warning(
"""
No residual was computed for either of 2 reasons:
1.) M is greater than number of frequencies.
2.) Singular values were detected and removed.
Will add -1 to list of residuals."""
)
res.append(-1)
mus.append(mu)
# Note by Julius: to show that code worked,
# I computed the residual as given in Schönleber paper
# Then, I compared to the lstsq residual computed by numpy.
# for documentation reasons, I kept the lines commented.
"""
Z_fit = R.eval(omega=omega, R=b[0])
if capacitance and not inductance:
Z_fit = np.sum([Z_fit, C.eval(omega=omega, C=1. / b[1])], axis=0)
elif capacitance and inductance:
Z_fit = np.sum([Z_fit, C.eval(omega=omega, C=1. / b[1])], axis=0)
Z_fit = np.sum([Z_fit, L.eval(omega=omega, L=b[2])], axis=0)
elif inductance and not capacitance:
Z_fit = np.sum([Z_fit, L.eval(omega=omega, L=b[1])], axis=0)
if M > 1:
taus = np.array([np.power(10, np.log10(tau_min) + m / (M - 1)
* np.log10(tau_max / tau_min)) for m in range(M)])
RCtaus = np.array([RC.eval(omega=self.omega, Rk=rks[m], tauk=taus[m])
for m in range(M)])
add = np.sum(RCtaus, axis=0)
Z_fit = np.sum([Z_fit, add], axis=0)
elif M == 1:
add = RC.eval(omega=self.omega, Rk=rks[0], tauk=tauk)
Z_fit = np.sum([Z_fit, add], axis=0)
rescomp = np.sum(((Z_fit.real - self.Z.real) * weight)**2
+ ((Z_fit.imag - self.Z.imag) * weight)**2)
if not np.isclose(res[-1], rescomp):
print("Detected difference between lstlsq and residual at M=",
M, res[-1], rescomp)
"""
M += 1
if M > maxM:
logger.warning("Reached maximum number of RC elements.")
break
logger.debug(f"\nmu = {mu}, c = {c}\n")
fitresult = {"R": b[0]}
# Found negative inductance and / or capacitance values when fitting.
# I am not sure if this is correct.
if capacitance and not inductance:
fitresult["C"] = 1.0 / b[1]
if b[1] < 0:
logger.warning("The LinKK-fitted capacitance is negative!")
elif capacitance and inductance:
fitresult["C"] = 1.0 / b[1]
if b[1] < 0:
logger.warning("The LinKK-fitted capacitance is negative!")
fitresult["L"] = b[2]
if b[2] < 0:
logger.warning("The LinKK-fitted inductance is negative!")
elif inductance and not capacitance:
fitresult["L"] = b[1]
if b[1] < 0:
logger.warning("The LinKK-fitted inductance is negative!")
M -= 1
if M > 1:
taus = np.array(
[
np.power(
10,
np.log10(tau_min) + m / (M - 1) * np.log10(tau_max / tau_min),
)
for m in range(M)
]
)
for m in range(M):
fitresult["R_" + str(m)] = rks[m]
fitresult["tau_" + str(m)] = taus[m]
elif M == 1:
fitresult["R_" + str(0)] = rks[0]
fitresult["tau_" + str(0)] = tau_max
logger.info(f"Used M={M} RC elements.")
return fitresult, mus, res
def _compute_mu(fit_values):
r"""Compute mu from Rk values.
Parameters
----------
fit_values: list
Contains Rk fit values.
Rks are used to compute :math:`\mu` as
described in [Schoenleber2014]_.
Returns
-------
double
Value of :math:`\mu`.
"""
neg = 0.0
pos = 0.0
for value in fit_values:
if np.less(value, 0.0):
neg += -value
else:
pos += value
if np.greater(pos, 0):
mu = 1.0 - (neg / pos)
else:
mu = -np.inf
return mu
def _calculate_statistics(model_result):
"""Calculate the fitting statistics.
Function taken from LMFIT source code
and modified
https://github.com/lmfit/lmfit-py/blob/05bbc07321480d02dcd13c122ba16d42ec3d4eae/lmfit/minimizer.py
It corrects for the weighting and thus makes fits comparable.
"""
model_result.residual = (
(model_result.best_fit - model_result.data).ravel().view(np.float64)
)
if isinstance(model_result.residual, np.ndarray):
model_result.chisqr = (model_result.residual**2).sum()
else:
model_result.chisqr = model_result.residual
model_result.redchi = model_result.chisqr / max(1, model_result.nfree)
# this is -2*loglikelihood
model_result.chisqr = max(model_result.chisqr, 1.0e-250 * model_result.ndata)
_neg2_log_likel = model_result.ndata * np.log(
model_result.chisqr / model_result.ndata
)
model_result.aic = _neg2_log_likel + 2 * model_result.nvarys
model_result.bic = (
_neg2_log_likel + np.log(model_result.ndata) * model_result.nvarys
)