import numpy as np
from .ec import ECMeasurement
from ..data_series import ValueSeries, TimeSeries
from ..exceptions import SeriesNotFoundError, BuildError
from .analysis_tools import (
tspan_passing_through,
calc_sharp_v_scan,
find_signed_sections,
)
[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()
"""
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
start_potential = None # see `redefine_cycle`
redox = None # see `redefine_cycle`
def __getitem__(self, key):
"""Given int list or slice key, return a CyclicVoltammogram with those cycles"""
if type(key) is slice:
start, stop, step = key.start, key.stop, key.step
if step is None:
step = 1
key = list(range(start, stop, step))
if type(key) in [int, list]:
if type(key) is list and not all([type(i) is 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)
try:
return super().__getitem__(item=key)
except SeriesNotFoundError:
if key == "cycle":
return self.cycle
@property
def cycle(self):
"""ValueSeries: the cycle number. The default selector. see `redefine_cycle`"""
try:
return self.selector
except TypeError:
# FIXME: This is what happens now when a single-cycle CyclicVoltammogram is
# saved and loaded.
return ValueSeries(
name="cycle",
unit_name="",
data=np.ones(self.t.shape),
tseries=self.potential.tseries,
)
[docs] def redefine_cycle(self, start_potential=None, redox=None):
"""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`.
"""
self.start_potential = start_potential
self.redox = redox
if start_potential is None:
old_cycle_series = self.cycle
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.v
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_behind = v[n:] < start_potential
if True not in mask_behind:
break
else:
n += (
np.argmax(mask_behind) + 5
) # have to be below V for 5 datapoints
# print('point number on way up: ' + str(n)) # debugging
mask_in_front = v[n:] > start_potential
if True not in mask_in_front:
break
else:
n += np.argmax(mask_in_front)
c += 1
cycle_vec[n:] = c # and subsequent points increase in cycle number
n += +5 # have to be above V for 5 datapoints
# print('point number on way down: ' + str(n)) # debugging
new_cycle_series = ValueSeries(
name="cycle",
unit_name="",
data=cycle_vec,
tseries=self.potential.tseries,
)
self["cycle"] = new_cycle_series
self.sel_str = "cycle"
return self.cycle
[docs] def select_sweep(self, vspan, t_i=None):
"""Return a CyclicVoltammogram for while 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.v, 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
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
def scan_rate(self, res_points=10):
"""The scan rate as a ValueSeries"""
t, v = self.grab("potential")
scan_rate_vec = calc_sharp_v_scan(t, v, res_points=res_points)
scan_rate_series = ValueSeries(
name="scan rate",
unit_name="V/s", # TODO: unit = potential.unit / potential.tseries.unit
data=scan_rate_vec,
tseries=self.potential.tseries,
)
# TODO: cache'ing, index accessibility
return scan_rate_series
[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 (iterable): 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]) # [mA] -> [A]
sweep_2 = self.select_sweep([vspan[-1], vspan[0]])
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 CyclicVoltammogram.get_timed_sweeps()
res_points (int): see CyclicVoltammogram.get_timed_sweeps()
"""
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} has {my_sweep_specs} and {other} 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}'s {my_spec} with {other}'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
diff_as_dict["raw_current_names"] = ("raw_current",)
cls = cls or CyclicVoltammogramDiff
diff = cls.from_dict(diff_as_dict)
diff.cv_1 = self
diff.cv_2 = other
return diff
[docs]class CyclicVoltammogramDiff(CyclicVoltammogram):
cv_1 = None
cv_2 = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.plot = self.plotter.plot
self.plot_diff = self.plotter.plot_diff
@property
def plotter(self):
"""The default plotter for CyclicVoltammogramDiff is CVDiffPlotter"""
if not self._plotter:
from ..plotters.ec_plotter import CVDiffPlotter
self._plotter = CVDiffPlotter(measurement=self)
return self._plotter