"""Module for representation and analysis of EC measurements"""
import numpy as np
from ..measurements import Measurement, append_series, time_shifted
from ..data_series import ValueSeries, ConstantValue
from ..exceptions import SeriesNotFoundError
from ..exporters.ec_exporter import ECExporter
[docs]class ECMeasurement(Measurement):
"""Class implementing electrochemistry measurements
TODO: Implement a unit library for current and potential, A_el and RE_vs_RHE
TODO: so that e.g. current can be seamlessly normalized to mass OR area.
The main job of this class is making sure that the ValueSeries most essential for
visualizing and normal electrochemistry measurements (i.e. excluding impedance spec.,
RRDE, etc, which would need new classes) are always available in the correct form as
the measurement is added with others, reduced to a selection, calibrated and
normalized, etc. These most important ValueSeries are:
- `potential`: The working-electrode potential typically in [V].
If `ec_meas` is an `ECMeasurement`, then `ec_meas["potential"]` always returns a
`ValueSeries` characterized by:
- calibrated and/or corrected, if the measurement has been calibrated with the
reference electrode potential (`RE_vs_RHE`, see `calibrate`) and/or corrected
for ohmic drop (`R_Ohm`, see `correct_ohmic_drop`).
- A name that makes clear any calibration and/or correction
- Data which spans the entire timespan of the measurement - i.e. whenever EC data
is being recorded, `potential` is there, even the name of the raw
`ValueSeries` (what the acquisition software calls it) changes. Indeed
`ec_meas["potential"].tseries` is the measurement's definitive time variable.
- `current`: The working-electrode current typically in [mA] or [mA/cm^2].
`ec_meas["current"]` always returns a `ValueSeries` characterized by:
- normalized if the measurement has been normalized with the electrode area
(`A_el`, see `normalize`)
- A name that makes clear whether it is normalized
- Data which spans the entire timespan of the measurement
- `selector`: A counter series distinguishing sections of the measurement program.
This is essential for analysis of complex measurements as it allows for
corresponding parts of experiments to be isolated and treated identically.
`selector` in `ECMeasurement` is defined to incriment each time one or more of
the following changes:
- `loop_number`: A parameter saved by some potentiostats (e.g. BioLogic) which
allow complex looped electrochemistry programs.
- `file_number`: The id of the component measurement from which each section of
the data (the origin of each `ValueSeries` concatenated to `potential`)
- `cycle_number`: An incrementer within a file saved by a potentiostat.
The names of these ValueSeries, which can also be used to index the measurement, are
conveniently available as properties:
- `ec_meas.t_str` is the name of the definitive time, which corresponds to potential.
- `ec_meas.E_str` is the name of the raw potential
- `ec_meas.V_str` is the name to the calibrated and/or corrected potential
- `ec_meas.I_str` is the name of the raw current
- `ec_meas.J_str` is the name of the normalized current.
- `ec_meas.sel_str` is the name of the default selector, i.e. "selector"
Numpy arrays from important `DataSeries` are also directly accessible via attributes:
- `ec_meas.t` for `ec_meas["potential"].t`
- `ec_meas.v` for `ec_meas["potential"].data`
- `ec_meas.j` for `ec_meas["current"].data`
`ECMeasurement` comes with an `ECPlotter` which either plots `potential` and
`current` against time (`ec_meas.plot_measurement()`) or plots `current` against
`potential (`ec_meas.plot_vs_potential()`).
It turns out that keeping track of current, potential, and selector when combining
datasets is enough of a job to fill a class. Thus, the more exciting
electrochemistry-related functionality should be implemented in inheriting classes
such as `CyclicVoltammogram`.
"""
extra_column_attrs = {
"ec_meaurements": {
"ec_technique",
"RE_vs_RHE",
"R_Ohm",
"raw_potential_names",
"A_el",
"raw_current_names",
}
}
def __init__(
self,
name,
*,
technique=None,
metadata=None,
s_ids=None,
series_list=None,
m_ids=None,
component_measurements=None,
reader=None,
plotter=None,
exporter=None,
sample=None,
lablog=None,
tstamp=None,
ec_technique=None,
t_str="time / [s]",
E_str="raw potential / [V]",
V_str="$U_{RHE}$ / [V]",
RE_vs_RHE=None,
R_Ohm=None,
raw_potential_names=("Ewe/V", "<Ewe>/V"), # TODO: reader must define this
I_str="raw current / [mA]",
J_str="J / [mA cm$^{-2}$]",
A_el=None,
raw_current_names=("I/mA", "<I>/mA"), # TODO: reader must define this
cycle_names=("cycle number",),
):
"""initialize an electrochemistry measurement
Args:
name (str): The name of the measurement
TODO: Decide if metadata needs the json string option.
TODO: See: https://github.com/ixdat/ixdat/pull/1#discussion_r546436991
metadata (dict): Free-form measurement metadata
technique (str): The measurement technique
s_ids (list of int): The id's of the measurement's DataSeries, if
to be loaded (instead of given directly in series_list)
series_list (list of DataSeries): The measurement's DataSeries
m_ids (list of int): The id's of the component measurements, if to be
loaded. None unless this is a combined measurement (typically
corresponding to more than one file).
component_measurements (list of Measurements): The measurements of which
this measurement is a combination
reader (Reader): The file reader (None unless read from a file)
plotter (Plotter): The visualization tool for the measurement
exporter (Exporter): The exporting tool for the measurement
sample (Sample): The (already loaded) sample being measured
lablog (LabLog): The log entry with e.g. notes taken during the measurement
tstamp (float): The nominal starting time of the measurement, used for
data selection, visualization, and exporting.
t_str (str): Name of the main time variable (corresponding to potential)
E_str (str): Name of raw potential (so called because potential is saved
as "Ewe/V" in biologic .mpt files)
V_str (str): Name of calibrated potential
RE_vs_RHE (float): Reference electrode potential in [V] on the RHE scale.
If RE_vs_RHE is not None, the measurement is considered *calibrated*,
and will use the calibrated potential `self[self.V_str]` by default
TODO: Unit
R_Ohm (float): Ohmic drop in [Ohm]. If R_Ohm is not None, the ohmic drop
is corrected for when returning potential.
TODO: Unit
raw_potential_names (tuple of str): The names of the VSeries which
represent raw working electrode current. This is typically how the data
acquisition software saves potential
I_str (str): Name of raw current
J_str (str): Name of normalized current
A_el (float): Area of electrode in [cm^2].
If A_el is not None, the measurement is considered *normalized*,
and will use the calibrated current `self[self.J_str]` by default
TODO: Unit
raw_current_names (tuple of str): The names of the VSeries which represent
raw working electrode current. This is typically how the data
acquisition software saves current.
"""
calibration = self.calibration if hasattr(self, "calibration") else None
super().__init__(
name,
technique=technique,
metadata=metadata,
s_ids=s_ids,
series_list=series_list,
m_ids=m_ids,
component_measurements=component_measurements,
reader=reader,
plotter=plotter,
exporter=exporter,
sample=sample,
lablog=lablog,
tstamp=tstamp,
) # FIXME: The super init sets self.calibration to None but I can't see why.
self.calibration = calibration or ECCalibration(RE_vs_RHE, A_el)
if RE_vs_RHE is not None: # if given as an arg RE_vs_RHE trumps calibration
self.RE_vs_RHE = RE_vs_RHE
if A_el is not None:
self.A_el = A_el
self.ec_technique = ec_technique
self.t_str = t_str
self.E_str = E_str
self.V_str = V_str
self.R_Ohm = R_Ohm
self.raw_potential_names = raw_potential_names
self.I_str = I_str
self.J_str = J_str
self.raw_current_names = raw_current_names
self.cycle_names = cycle_names
self.sel_str = "selector"
self.cycle_str = "cycle_number"
self.plot_vs_potential = self.plotter.plot_vs_potential
self._raw_potential = None
self._raw_current = None
self._selector = None
self._file_number = None
if self.potential:
if all(
[
(current_name not in self.series_names)
for current_name in self.raw_current_names
]
):
self.series_list.append(
ConstantValue(
name=self.raw_current_names[0],
unit_name="mA",
value=0,
)
)
self._populate_constants() # So that OCP currents are included as 0.
# TODO: I don't like this. ConstantValue was introduced to facilitate
# ixdat's laziness, but I can't find anywhere else to put the call to
# _populate_constants() that can find the right tseries. This is a
# violation of laziness as bad as what it was meant to solve.
if all(
[
(cycle_name not in self.series_names)
for cycle_name in self.cycle_names
]
):
self.series_list.append(
ConstantValue(
name=self.cycle_names[0],
unit_name=None,
value=0,
)
)
self._populate_constants() # So that everything has a cycle number
@property
def A_el(self):
return self.calibration.A_el
@A_el.setter
def A_el(self, A_el):
self.calibration.A_el = A_el
@property
def RE_vs_RHE(self):
return self.calibration.RE_vs_RHE
@RE_vs_RHE.setter
def RE_vs_RHE(self, RE_vs_RHE):
self.calibration.RE_vs_RHE = RE_vs_RHE
def _populate_constants(self):
"""Replace any ConstantValues with ValueSeries on potential's tseries
TODO: This function flagrantly violates laziness. Not only does it fill up all
the ConstantValue's with long vectors before they're needed, it also forces
raw_potential to be built before it is needed.
A lazier solution is needed.
"""
for (i, s) in enumerate(self.series_list):
if isinstance(s, ConstantValue):
tseries = self.potential.tseries
series = s.get_vseries(tseries=tseries)
self.series_list[i] = series
def __getitem__(self, item):
"""Return the (concatenated) (time-shifted) `DataSeries` with name `item`
If `item` matches one of the strings for managed series described in the class
docstring, item retrieval will still first look in `series_list` (see
`ixdat.Measurement.__getitem__()` for this inherited behavior), but will then
return the corresponding managed attribute of this `ECMeasurement`. (See
class docstring.) Only if `item` matches neither these strings nor the names of
the `DataSeries` in `series_list` is a `SeriesNotFoundError` raised.
TODO: I would like to decorate this with a with_time_shifted() decorator to
enforce here (rather than all over as now) that the time is referenced
to the measurement tstamp. But not obvious to me how the decorator would have
access to self.tstamp.
"""
try:
return super().__getitem__(item)
except SeriesNotFoundError:
if item == self.t_str: # master time (potential's tseries)
return self.potential.tseries
if item == self.E_str: # raw potential
return self.raw_potential
elif item == self.V_str: # (calibrated) (corrected) potential
return self.potential
elif item == self.I_str: # raw current
return self.raw_current
elif item == self.J_str: # (normalized) current
return self.current
elif item == self.sel_str: # selector
return self.selector
elif item == "potential":
return self.potential
elif item == "current":
return self.current
elif item == "raw_potential":
return self.raw_potential
elif item == "raw_current":
return self.raw_current
elif item == self.potential.name:
return self.potential
elif item == self.current.name:
return self.current
raise SeriesNotFoundError(f"{self} doesn't have item '{item}'")
@property
def raw_potential(self):
"""Return a time-shifted ValueSeries for the raw potential, built first time."""
if not self._raw_potential:
try:
self._find_or_build_raw_potential()
except SeriesNotFoundError as e:
print(f"Warning!!! {self} encountered: {e}")
return
return time_shifted(self._raw_potential, tstamp=self.tstamp)
# FIXME. Hidden attributes not scaleable cache'ing
def _find_or_build_raw_potential(self):
"""Build the raw potential and store it data_series and as self._raw_potential()
# TODO, it should instead be stored in a `cached_series_list`.
This works by finding all the series that have names matching the raw potential
names list `self.raw_potential_names` (which should be provided by the Reader).
If there is only one, it just shifts it to t=0 at self.tstamp.
FIXME:
If there are multiple it appends them with t=0 at self.tstamp. In this
case it also appends the `TimeSeries` to `series_list` since *bad things
might happen?* if the `TimeSeries` of a `ValueSeries` in `series_list` is
not itself in `series_list`. But this results in redundant TimeSeries.
"""
potential_series_list = [
s for s in self.series_list if s.name in self.raw_potential_names
]
if len(potential_series_list) == 1:
self._raw_potential = time_shifted(
potential_series_list[0], tstamp=self.tstamp
)
elif len(potential_series_list) > 1:
raw_potential = append_series(potential_series_list, tstamp=self.tstamp)
if self._raw_current and self._raw_current.tseries == raw_potential.tseries:
# Then we can re-use the tseries from raw_current rather than
# saving a new one :D
potential_tseries = self._raw_current.tseries
else:
potential_tseries = raw_potential.tseries
self.series_list.append(potential_tseries)
self._raw_potential = ValueSeries(
name=self.E_str,
data=raw_potential.data,
unit_name=raw_potential.unit_name,
tseries=potential_tseries,
)
self[
self.E_str
] = self._raw_potential # TODO: Better cache'ing. This saves.
else:
raise SeriesNotFoundError(
f"{self} does not have a series corresponding to raw potential."
f" Looked for series with names in {self.raw_potential_names}"
)
@property
def raw_current(self):
"""Return a time-shifted ValueSeries for the raw current, built first time."""
if not self._raw_current:
self._find_or_build_raw_current()
return time_shifted(self._raw_current, tstamp=self.tstamp)
# FIXME. Hidden attributes not scaleable cache'ing
def _find_or_build_raw_current(self):
"""Build the raw current and store it data_series and as self._raw_current()
This works the way as `_find_or_build_raw_potential`. See the docstring there.
FIXME: it also has the same problems.
"""
current_series_list = [
s for s in self.series_list if s.name in self.raw_current_names
]
if len(current_series_list) == 1:
self._raw_current = time_shifted(current_series_list[0], tstamp=self.tstamp)
elif len(current_series_list) > 1:
raw_current = append_series(current_series_list, tstamp=self.tstamp)
if self._raw_potential and (
self._raw_potential.tseries == raw_current.tseries
):
# Then we can re-use the tseries from raw_potential rather than
# saving a new one :D
current_tseries = self._raw_potential.tseries
else:
current_tseries = raw_current.tseries
self.series_list.append(current_tseries)
self._raw_current = ValueSeries(
name=self.I_str,
data=raw_current.data,
unit_name=raw_current.unit_name,
tseries=current_tseries,
)
self[
self.I_str
] = self._raw_current # TODO: better cache'ing. This is saved
else:
raise SeriesNotFoundError(
f"{self} does not have a series corresponding to raw current."
f" Looked for series with names in {self.raw_current_names}"
)
[docs] def calibrate(self, RE_vs_RHE=None, A_el=None, R_Ohm=None):
"""Calibrate the EC measurement (all args optional)
Args:
RE_vs_RHE (float): reference electode potential on RHE scale in [V]
A_el (float): electrode area in [cm^2]
R_Ohm (float): ohmic drop resistance in [Ohm]
"""
if RE_vs_RHE is not None: # it can be 0!
self.calibrate_RE(RE_vs_RHE=RE_vs_RHE)
if A_el:
self.normalize_current(A_el=A_el)
if R_Ohm:
self.correct_ohmic_drop(R_Ohm=R_Ohm)
[docs] def calibrate_RE(self, RE_vs_RHE):
"""Calibrate the reference electrode by providing `RE_vs_RHE` in [V].
Return string: The name of the calibrated potential
"""
self.RE_vs_RHE = RE_vs_RHE
return self.V_str
[docs] def normalize_current(self, A_el):
"""Normalize current to electrod surface area by providing `A_el` in [cm^2].
Return string: The name of the normalized current
"""
self.A_el = A_el
return self.J_str
[docs] def correct_ohmic_drop(self, R_Ohm):
"""Correct for ohmic drop by providing `R_Ohm` in [Ohm].
Return string: The name of the corrected potential
"""
self.R_Ohm = R_Ohm
return self.V_str
@property
def potential(self):
"""The ValueSeries with the ECMeasurement's potential.
This is result of the following:
- Starts with `self.raw_potential`
- if the measurement is "calibrated" i.e. `RE_vs_RHE` is not None: add
`RE_vs_RHE` to the potential data and change its name from `E_str` to `V_str`
- if the measurement is "corrected" i.e. `R_Ohm` is not None: subtract
`R_Ohm` times the raw current from the potential and add " (corrected)" to
its name.
"""
if self.V_str in self.series_names:
return self[self.V_str]
raw_potential = self.raw_potential
if self.RE_vs_RHE is None and self.R_Ohm is None:
return raw_potential
fixed_V_str = raw_potential.name
fixed_potential_data = raw_potential.data
fixed_unit_name = raw_potential.unit_name
if self.RE_vs_RHE is not None:
fixed_V_str = self.V_str
fixed_potential_data = fixed_potential_data + self.RE_vs_RHE
fixed_unit_name = "V <RHE>"
if self.R_Ohm:
fixed_V_str += " (corrected)"
fixed_potential_data = (
fixed_potential_data
- self.R_Ohm * self.grab_for_t("raw_current", raw_potential.t) * 1e-3
) # TODO: Units. The 1e-3 here is to bring raw_current.data from [mA] to [A]
return ValueSeries(
name=fixed_V_str,
data=fixed_potential_data,
unit_name=fixed_unit_name,
tseries=raw_potential.tseries,
) # TODO: Better cache'ing. This is not cached at all.
@property
def current(self):
"""The ValueSeries with the ECMeasurement's current.
This is result of the following:
- Starts with `self.raw_current`
- if the measurement is "normalized" i.e. `A_el` is not None: divide the current
data by `A_el`, change its name from `I_str` to `J_str`, and add `/cm^2` to
its unit.
"""
if self.J_str in self.series_names:
return self[self.J_str]
raw_current = self.raw_current
if self.A_el is None:
return raw_current
else:
return ValueSeries(
name=self.J_str,
data=raw_current.data / self.A_el,
unit_name=raw_current.unit_name + "/cm^2",
tseries=raw_current.tseries,
)
[docs] def grab_potential(self, tspan=None, cal=True):
"""Return t and potential (if cal else raw_potential) [V] vectors cut by tspan"""
if cal:
return self.grab("potential", tspan=tspan)
else:
return self.grab("raw_potential", tspan=tspan)
[docs] def grab_current(self, tspan=None, norm=True):
"""Return t [s] and current (if cal else raw_current) [V] vectors cut by tspan"""
if norm:
return self.grab("current", tspan=tspan)
else:
return self.grab("raw_current", tspan=tspan)
@property
def t(self):
"""The definitive time np array of the measurement, corresponding to potential"""
return self.potential.t.copy()
@property
def v(self):
"""The potential [V] numpy array of the measurement"""
return self.potential.data.copy()
@property
def j(self):
"""The current ([mA] or [mA/cm^2]) numpy array of the measurement"""
return self.current.data.copy()
@property
def plotter(self):
"""The default plotter for ECMeasurement is ECPlotter"""
if not self._plotter:
from ..plotters.ec_plotter import ECPlotter
self._plotter = ECPlotter(measurement=self)
return self._plotter
@property
def exporter(self):
"""The default plotter for ECMeasurement is ECExporter"""
if not self._exporter:
self._exporter = ECExporter(measurement=self)
return self._exporter
@property
def selector(self):
"""The ValuSeries which is used by default to select parts of the measurement.
See the class docstring for details.
"""
if self.sel_str not in self.series_names:
self._build_selector()
return time_shifted(self[self.sel_str], tstamp=self.tstamp)
def _build_selector(self, sel_str=None):
"""Build `selector` from `cycle number`, `loop_number`, and `file_number`
See the class docstring for details.
"""
sel_str = sel_str or self.sel_str
changes = np.tile(False, self.t.shape)
col_list = ["cycle number", "loop_number", "file_number"]
for col in col_list:
if col in self.series_names:
values = self[col].data
if len(values) == 0:
print("WARNING: " + col + " is empty")
continue
elif not len(values) == len(changes):
print("WARNING: " + col + " has an unexpected length")
continue
n_down = np.append(
values[0], values[:-1]
) # comparing with n_up instead puts selector a point ahead
changes = np.logical_or(changes, n_down < values)
selector = np.cumsum(changes)
selector_series = ValueSeries(
name=sel_str,
unit_name="",
data=selector,
tseries=self.potential.tseries,
)
self[self.sel_str] = selector_series # TODO: Better cache'ing. This gets saved.
@property
def cycle_number(self):
"""The cycle number ValueSeries, requires building from component measurements"""
cycle_series_list = [s for s in self.series_list if s.name in self.cycle_names]
if len(cycle_series_list) == 1:
return time_shifted(cycle_series_list[0], tstamp=self.tstamp)
elif len(cycle_series_list) > 1:
cycle = append_series(cycle_series_list, tstamp=self.tstamp)
return ValueSeries(
name=self.cycle_str,
data=cycle.data,
unit_name=cycle.unit_name,
tseries=cycle.tseries,
)
else:
return
raise SeriesNotFoundError(
f"{self} does not have a series corresponding to cycle number."
)
# TODO: better cache'ing. This one is not cache'd at all
@property
def file_number(self):
"""The file number ValueSeries, requires building from component measurements."""
if "file_number" in self.series_names:
self._build_file_number()
return time_shifted(self["file_number"], tstamp=self.tstamp)
def _build_file_number(self):
"""Build the """
file_number_series_list = []
for m in self.component_measurements:
vseries = m.potential
file_number_series = ValueSeries(
name="file_number",
unit_name="",
data=np.tile(m.id, vseries.t.shape),
tseries=vseries.tseries,
)
file_number_series_list.append(file_number_series)
file_number = append_series(file_number_series_list, tstamp=self.tstamp)
self[
"file_number"
] = file_number # TODO: better cache'ing. This one gets saved.
[docs] def as_cv(self):
"""Convert self to a CyclicVoltammogram"""
from .cv import CyclicVoltammogram
self_as_dict = self.as_dict()
self_as_dict["series_list"] = self.series_list
self_as_dict["technique"] = "CV"
del self_as_dict["s_ids"]
# Note, this works perfectly! All needed information is in self_as_dict :)
return CyclicVoltammogram.from_dict(self_as_dict)
[docs]class ECCalibration:
"""A small container for RHE_vs_RE and A_el"""
def __init__(self, RE_vs_RHE=None, A_el=None):
self.RE_vs_RHE = RE_vs_RHE
self.A_el = A_el