import numpy as np
from .ec import ECMeasurement
from ..data_series import ValueSeries, TimeSeries
from ..exceptions import BuildError, SeriesNotFoundError
from ..calculators.scan_rate_tools import (
tspan_passing_through,
find_signed_sections,
)
from ..plotters import CVDiffPlotter, get_color_from_cmap, add_colorbar
from ..tools import deprecate
[docs]class CyclicVoltammogram(ECMeasurement):
"""Class for cyclic voltammetry measurements.
Onto ECMeasurement, this adds:
- a property `cycle` which is a ValueSeries on the same TimeSeries as potential,
which counts cycles. "cycle" becomes the Measurement's `sel_str`. Indexing with
integer or iterable selects according to `cycle`.
- functions for quantitatively comparing cycles (like a stripping cycle, base cycle)
- the default plot() is plot_vs_potential()
"""
essential_series_names = ("t", "raw_potential", "raw_current", "cycle")
selector_name = "cycle"
built_in_calculator_types = ECMeasurement.built_in_calculator_types + [
"scan_rate_calculator"
]
"""Name of the default selector"""
def __init__(self, *args, **kwargs):
"""Only reason to have an __init__ here is to set the default plot()"""
super().__init__(*args, **kwargs)
self.plot = self.plotter.plot_vs_potential # gets the right docstrings! :D
try:
_ = self["cycle"]
except SeriesNotFoundError:
median_potential = 1 / 2 * (np.max(self.U) + np.min(self.U))
self.redefine_cycle(start_potential=median_potential, redox=True)
self.start_potential = None # see `redefine_cycle`
self.redox = None # see `redefine_cycle`
def __getitem__(self, key):
"""Given int list or slice key, return a CyclicVoltammogram with those cycles"""
if isinstance(key, slice):
start, stop, step = key.start, key.stop, key.step
if step is None:
step = 1
key = list(range(start, stop, step))
if isinstance(key, (int, list)):
if isinstance(key, list) and not all([isinstance(i, int) for i in key]):
print("can't get an item of type list unless all elements are int")
print(f"you tried to get key = {key}.")
raise AttributeError
return self.select(key)
return super().__getitem__(key)
[docs] def redefine_cycle(self, start_potential=None, redox=None, N_points=5):
"""Build `cycle` which iterates when passing through start_potential
Args:
start_potential (float): The potential in [V] at which the cycle counter will
iterate. If start_potential is not given, the cycle is just the
`selector` inherited from ECMeasurement shifted to start at 0.
redox (bool): True (or 1) for anodic, False (or 0) for cathodic. The
direction in which the potential is scanning through start_potential to
trigger an iteration of `cycle`.
N_points (int): The number of consecutive points for which the potential
needs to be above (redox=True) or below (redox=False) the
start_potential for the new cycle to register.
"""
self.start_potential = start_potential
self.redox = redox
if start_potential is None:
old_cycle_series = self["cycle_number"]
new_cycle_series = ValueSeries(
name="cycle",
unit_name=old_cycle_series.unit_name,
data=old_cycle_series.data - min(old_cycle_series.data),
tseries=old_cycle_series.tseries,
)
else:
cycle_vec = np.zeros(self.t.shape)
c = 0
n = 0
N = len(self.t)
v = self.U
if not redox:
# easiest way to reverse directions is to use the same > < operators
# but negate the arguments
start_potential = -start_potential
v = -v
while n < N:
# mask on remaining potential, True wherever behind the start potential:
mask_behind = v[n:] < start_potential
if True not in mask_behind:
# if the potenential doesn't go behind start potential again, then
# there are no more cycles
break
else:
# the potential has to get behind the start potential for at least
# N_points data points before a new cycle can start.
n += np.argmax(mask_behind) + N_points
# a mask on remaining potential, True wherever ahead of start potential:
mask_in_front = v[n:] > start_potential
if True not in mask_in_front: # again, no more cycles.
break
else:
# We've already been behind for N_points, so as soon as the
# potential gets ahead of the start_potential, a new cycle begins!
n += np.argmax(mask_in_front)
c += 1
cycle_vec[n:] = c # and subsequent points increase in cycle number
n += N_points # have to be above start_potential for N_points
# datapoints before getting behind it for this to count as a cycle.
new_cycle_series = ValueSeries(
name="cycle",
unit_name="",
data=cycle_vec,
tseries=self.potential.tseries,
)
self.replace_series("cycle", new_cycle_series)
[docs] def select_sweep(self, vspan, t_i=None):
"""Return the cut of the CV for which the potential is sweeping through vspan
Args:
vspan (iter of float): The range of self.potential for which to select data.
Vspan defines the direction of the sweep. If vspan[0] < vspan[-1], an
oxidative sweep is returned, i.e. one where potential is increasing.
If vspan[-1] < vspan[0], a reductive sweep is returned.
t_i (float): Optional. Time before which the sweep can't start
"""
tspan = tspan_passing_through(
t=self.t,
v=self.U,
vspan=vspan,
t_i=t_i,
)
return self.cut(tspan=tspan)
[docs] def integrate(self, item, tspan=None, vspan=None, ax=None):
"""Return the time integral of item while time in tspan or potential in vspan
Args:
item (str): The name of the ValueSeries to integrate
tspan (iter of float): A time interval over which to integrate it
vspan (iter of float): A potential interval over which to integrate it
"""
if vspan:
return self.select_sweep(
vspan=vspan, t_i=tspan[0] if tspan else None
).integrate(item, ax=ax)
return super().integrate(item, tspan, ax=ax)
@property
@deprecate("0.1", "Use a look-up, i.e. `ec_meas['scan_rate']`, instead.", "0.3.1")
def scan_rate(self):
return self["scan_rate"]
[docs] def get_timed_sweeps(self, v_scan_res=5e-4, res_points=10):
"""Return list of [(tspan, type)] for all the potential sweeps in self.
There are three types: "anodic" (positive scan rate), "cathodic" (negative scan
rate), and "hold" (zero scan rate)
Args:
v_scan_res (float): The minimum scan rate considered significantly different
than zero, in [V/s]. Defaults to 5e-4 V/s (0.5 mV/s). May need be higher
for noisy potential, and lower for very low scan rates.
res_points (int): The minimum number of points to be considered a sweep.
During a sweep, a potential difference of at least `v_res` should be
scanned through every `res_points` points.
"""
t = self.t
ec_sweep_types = {
"positive": "anodic",
"negative": "cathodic",
"zero": "hold",
}
indexed_sweeps = find_signed_sections(
self["scan_rate"].data, x_res=v_scan_res, res_points=res_points
)
timed_sweeps = []
for (i_start, i_finish), general_sweep_type in indexed_sweeps:
timed_sweeps.append(
((t[i_start], t[i_finish]), ec_sweep_types[general_sweep_type])
)
return timed_sweeps
[docs] def calc_capacitance(self, vspan):
"""Return the capacitance in [F], calculated by the first sweeps through vspan
Args:
vspan (iter of floats): The potential range in [V] to use for capacitance
"""
sweep_1 = self.select_sweep(vspan)
v_scan_1 = np.mean(sweep_1.grab("scan_rate")[1]) # [V/s]
I_1 = np.mean(sweep_1.grab("raw_current")[1]) * 1e-3 # [mA] -> [A]
sweep_2 = self.select_sweep([vspan[-1], vspan[0]], t_i=max(sweep_1.t + 1))
v_scan_2 = np.mean(sweep_2.grab("scan_rate")[1]) # [V/s]
I_2 = np.mean(sweep_2.grab("raw_current")[1]) * 1e-3 # [mA] -> [A]
cap = 1 / 2 * (I_1 / v_scan_1 + I_2 / v_scan_2) # [A] / [V/s] = [C/V] = [F]
return cap
[docs] def diff_with(self, other, v_list=None, cls=None, v_scan_res=0.001, res_points=10):
"""Return a CyclicVotammagramDiff of this CyclicVotammagram with another one
Each anodic and cathodic sweep in other is lined up with a corresponding sweep
in self. Each variable given in v_list (defaults to just "current") is
interpolated onto self's potential and subtracted from self.
Args:
other (CyclicVoltammogram): The cyclic voltammogram to subtract from self.
v_list (list of str): The names of the series to calculate a difference
between self and other for (defaults to just "current").
cls (ECMeasurement subclass): The class to return an object of. Defaults to
CyclicVoltammogramDiff.
v_scan_res (float): see :meth:`get_timed_sweeps`
res_points (int): see :meth:`get_timed_sweeps`
"""
if not type(self) is CyclicVoltammogram:
raise NotImplementedError(
"CyclicVoltammogram.diff_with() is not implemented for "
f"cyclic voltammograms of type {type(self)}"
)
vseries = self.potential
tseries = vseries.tseries
series_list = [tseries, self["raw_potential"], self["cycle"]]
v_list = v_list or ["current", "raw_current"]
if "potential" in v_list:
raise BuildError(
f"v_list={v_list} is invalid. 'potential' is used to interpolate."
)
my_sweep_specs = [
spec
for spec in self.get_timed_sweeps(
v_scan_res=v_scan_res, res_points=res_points
)
if spec[1] in ["anodic", "cathodic"]
]
others_sweep_specs = [
spec
for spec in other.get_timed_sweeps(
v_scan_res=v_scan_res, res_points=res_points
)
if spec[1] in ["anodic", "cathodic"]
]
if not len(my_sweep_specs) == len(others_sweep_specs):
raise BuildError(
"Can only make diff of CyclicVoltammograms with same number of sweeps."
f"{self!r} has {my_sweep_specs} and {other!r} has {others_sweep_specs}."
)
diff_values = {name: np.array([]) for name in v_list}
t_diff = np.array([])
for my_spec, other_spec in zip(my_sweep_specs, others_sweep_specs):
sweep_type = my_spec[1]
if not other_spec[1] == sweep_type:
raise BuildError(
"Corresponding sweeps must be of same type when making diff."
f"Can't align {self!r}'s {my_spec} with {other!r}'s {other_spec}."
)
my_tspan = my_spec[0]
other_tspan = other_spec[0]
my_t, my_potential = self.grab(
"potential", my_tspan, include_endpoints=False
)
t_diff = np.append(t_diff, my_t)
other_t, other_potential = other.grab(
"potential", other_tspan, include_endpoints=False
)
if sweep_type == "anodic":
other_t_interp = np.interp(
np.sort(my_potential), np.sort(other_potential), other_t
)
elif sweep_type == "cathodic":
other_t_interp = np.interp(
np.sort(-my_potential), np.sort(-other_potential), other_t
)
else:
continue
for name in v_list:
my_v = self.grab_for_t(name, my_t)
other_v = other.grab_for_t(name, other_t_interp)
diff_v = my_v - other_v
diff_values[name] = np.append(diff_values[name], diff_v)
t_diff_series = TimeSeries(
name="time/[s] for diffs", unit_name="s", data=t_diff, tstamp=self.tstamp
) # I think this is the same as self.potential.tseries
series_list.append(t_diff_series)
for name, data in diff_values.items():
series_list.append(
ValueSeries(
name=name,
unit_name=self[name].unit_name,
data=data,
tseries=t_diff_series,
)
)
diff_as_dict = self.as_dict()
del diff_as_dict["s_ids"]
diff_as_dict["series_list"] = series_list
cls = cls or CyclicVoltammogramDiff
diff = cls.from_dict(diff_as_dict)
# TODO: pass cv_compare_1 and cv_compare_2 to CyclicVoltammogramDiff as dicts
diff.cv_compare_1 = self
diff.cv_compare_2 = other
return diff
[docs] def plot_cycles(self, ax=None, cmap_name="jet"):
"""Plot the cycles on a color scale.
Args:
ax (mpl.Axis): The axes to plot on. A new one is made by default
cmap_name (str): The name of the colormap to use. Defaults to "jet", which
ranges from blue to red
"""
cycle_numbers = set(self["cycle"].data)
c_max = max(cycle_numbers)
for c in cycle_numbers:
color = get_color_from_cmap(c / c_max, cmap_name=cmap_name)
ax = self[int(c)].plot(ax=ax, color=color)
add_colorbar(
ax, cmap_name, vmin=min(cycle_numbers), vmax=c_max, label="cycle number"
)
return ax
[docs]class CyclicVoltammagram(CyclicVoltammogram):
# FIXME: decorating the class itself doesn't work because the callable returned
# by the decorator does not have the class methods. But this works fine.
@deprecate("0.1", "Use `CyclicVoltammogram` instead ('o' replaces 'a').", "0.3.1")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
[docs]class CyclicVoltammogramDiff(CyclicVoltammogram):
default_plotter = CVDiffPlotter
cv_compare_1 = None
cv_compare_2 = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.plot = self.plotter.plot
self.plot_diff = self.plotter.plot_diff
self.plotter = CVDiffPlotter(measurement=self)