From 8fc238e5862f0cdad9f24c09af1bbea6b1c5c79f Mon Sep 17 00:00:00 2001 From: kkrempl Date: Sat, 19 Dec 2020 16:29:30 +0100 Subject: [PATCH 001/118] fix import --- src/ixdat/techniques/ec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index 084d00a2..17c7d35b 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -1,6 +1,6 @@ """Module for representation and analysis of EC measurements""" -from . import Measurement +from ..measurements import Measurement class ECMeasurement(Measurement): From ccfb5a3d4778148d88a985134f05bc020fb72d62 Mon Sep 17 00:00:00 2001 From: kkrempl Date: Sat, 19 Dec 2020 16:31:21 +0100 Subject: [PATCH 002/118] fix import --- src/ixdat/techniques/ms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index 6f0fcdaa..7b6ffb78 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -1,6 +1,6 @@ """Module for representation and analysis of MS measurements""" -from . import Measurement +from .. import Measurement class MSMeasurement(Measurement): From d45369d854812ecbcaf20e539c84a17656b2c365 Mon Sep 17 00:00:00 2001 From: kkrempl Date: Fri, 8 Jan 2021 16:28:45 +0100 Subject: [PATCH 003/118] pkl readers --- src/ixdat/readers/ec_ms.py | 37 +++++++++++++++++++++++++++++++++++-- src/ixdat/techniques/ms.py | 2 +- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/ixdat/readers/ec_ms.py b/src/ixdat/readers/ec_ms.py index 6a30d1cf..ad8590ce 100644 --- a/src/ixdat/readers/ec_ms.py +++ b/src/ixdat/readers/ec_ms.py @@ -1,9 +1,42 @@ from . import TECHNIQUE_CLASSES +import pickle +from .data_series import TimeSeries, ValueSeries +from .measurements import Measurement ECMSMeasruement = TECHNIQUE_CLASSES["EC-MS"] class EC_MS_CONVERTER: - def read(self): - # return ECMSMeasurement(**obj_as_dict) + def __init__(self): + print('Reader of old ECMS .pkl files') + + def read(self, file_path): + + with open(file_path, "rb") as f: + data = pickle.load(f) + + cols_str = data['data_cols'] + data = Measurement("test", + technique="EC_MS", + ) + for col in cols: + + if col[0] == "M" and col[-1] == "x": + cols_list.append(TimeSeries(col, "s", data[col], data["tstamp"])) + + if col == "time/s": + cols_list.append(TimeSeries(col, "s", data[col], data["tstamp"])) + + for col in cols: + + if col[0] == "M" and col[-1] == "y": + cols_list.append(ValueSeries(col, "A", data[col], data["tstamp"], tseries=)) + + if col == "I/mA" + cols_list.append(ValueSeries(col, "mA", data[col], data["tstamp"])) + + if col == "Ewe/V" + cols_list.append(ValueSeries(col, "V", data[col], data["tstamp"])) + + pass diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index 7b6ffb78..104bd584 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -1,6 +1,6 @@ """Module for representation and analysis of MS measurements""" -from .. import Measurement +from ..measurements import Measurement class MSMeasurement(Measurement): From 4a007a0c87e9eb8d2cf6f5f59e263553470cf0f6 Mon Sep 17 00:00:00 2001 From: kkrempl Date: Sat, 9 Jan 2021 10:32:10 +0100 Subject: [PATCH 004/118] EC_MS .pkl reader --- src/ixdat/readers/ec_ms.py | 49 +++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/src/ixdat/readers/ec_ms.py b/src/ixdat/readers/ec_ms.py index ad8590ce..dd849cf2 100644 --- a/src/ixdat/readers/ec_ms.py +++ b/src/ixdat/readers/ec_ms.py @@ -16,27 +16,42 @@ def read(self, file_path): data = pickle.load(f) cols_str = data['data_cols'] - data = Measurement("test", - technique="EC_MS", - ) - for col in cols: + cols_list = [] + for col in cols_str: if col[0] == "M" and col[-1] == "x": - cols_list.append(TimeSeries(col, "s", data[col], data["tstamp"])) + cols_list.append(TimeSeries( + col, "s", + data[col], data["tstamp"] + )) if col == "time/s": - cols_list.append(TimeSeries(col, "s", data[col], data["tstamp"])) + cols_list.append(TimeSeries( + col, "s", + data[col], data["tstamp"] + )) - for col in cols: + measurement = Measurement('tseries_ms', + technique = 'EC_MS', + series_list=cols_list) - if col[0] == "M" and col[-1] == "y": - cols_list.append(ValueSeries(col, "A", data[col], data["tstamp"], tseries=)) - - if col == "I/mA" - cols_list.append(ValueSeries(col, "mA", data[col], data["tstamp"])) - - if col == "Ewe/V" - cols_list.append(ValueSeries(col, "V", data[col], data["tstamp"])) - - pass + for col in cols_str: + if col[0] == "M" and col[-1] == "y": + cols_list.append(ValueSeries( + col, "A", + data[col], + tseries=measurement[col[:-1] + 'x'] + )) + if col == 'Ewe/V' or col == 'I/mA': + cols_list.append(ValueSeries( + col, "A", + data[col], + tseries=measurement['time/s'] + )) + + measurement = Measurement('tseries_ms', + technique = 'EC_MS', + series_list=cols_list) + + return measurement From 0d2f2e5e21cf2be139a40c783ecbe50e9594af5b Mon Sep 17 00:00:00 2001 From: kkrempl Date: Sun, 24 Jan 2021 18:59:39 +0100 Subject: [PATCH 005/118] Implementation of the deconvolution class and further functionality. --- src/ixdat/readers/ec_ms.py | 26 ++- src/ixdat/techniques/deconvolution.py | 253 ++++++++++++++++++++++++++ src/ixdat/techniques/ec.py | 17 +- src/ixdat/techniques/ec_ms.py | 3 + src/ixdat/techniques/ms.py | 50 +++++ 5 files changed, 342 insertions(+), 7 deletions(-) create mode 100644 src/ixdat/techniques/deconvolution.py diff --git a/src/ixdat/readers/ec_ms.py b/src/ixdat/readers/ec_ms.py index dd849cf2..fc0e037c 100644 --- a/src/ixdat/readers/ec_ms.py +++ b/src/ixdat/readers/ec_ms.py @@ -1,17 +1,31 @@ from . import TECHNIQUE_CLASSES import pickle -from .data_series import TimeSeries, ValueSeries -from .measurements import Measurement +from ..data_series import TimeSeries, ValueSeries +from ..measurements import Measurement +from ..techniques.ec_ms import ECMSMeasurement + ECMSMeasruement = TECHNIQUE_CLASSES["EC-MS"] class EC_MS_CONVERTER: +"""Class that converts old .pkl files obtained from the legacy EC-MS package +into a ixdat ECMSMeasurement object. Metadata from the .pkl is omitted. +""" def __init__(self): print('Reader of old ECMS .pkl files') def read(self, file_path): + """Return an ECMSMeasurement with the data recorded in path_to_file + + This loops through the keys of the EC-MS dict and searches for MS and + EC data. Names the dataseries according to their names in the original + dict. Omitts any other data as well as metadata. + Args: + path_to_file (Path): The full abs or rel path including the + ".pkl" extension. + """ with open(file_path, "rb") as f: data = pickle.load(f) @@ -39,7 +53,7 @@ def read(self, file_path): for col in cols_str: if col[0] == "M" and col[-1] == "y": cols_list.append(ValueSeries( - col, "A", + col[:-2], "A", data[col], tseries=measurement[col[:-1] + 'x'] )) @@ -50,8 +64,10 @@ def read(self, file_path): tseries=measurement['time/s'] )) - measurement = Measurement('tseries_ms', + measurement = ECMSMeasurement(file_path, technique = 'EC_MS', - series_list=cols_list) + series_list=cols_list, + reader=self, + tstamp=data["tstamp"]) return measurement diff --git a/src/ixdat/techniques/deconvolution.py b/src/ixdat/techniques/deconvolution.py new file mode 100644 index 00000000..48c281cf --- /dev/null +++ b/src/ixdat/techniques/deconvolution.py @@ -0,0 +1,253 @@ +"""Module for analysis of EC-MS measurements with impulse response and +deconvolution of mass transport effects to obtain partial current densities.""" + +from .ec_ms import ECMSMeasurement + + + +class DecoMeasurement(ECMSMeasurement): + """Class implementing deconvolution of EC-MS data""" + + def __intit__(self, name, **kwargs): + """initialize a deconvolution EC-MS measurement + + Args: + name (str): The name of the measurement""" + super().__init__(name, **kwargs) + + def get_partial_current(self, + signal_name, + kernel_obj, + tspan=None, + t_bg=None, + snr=10): + """Returns the deconvoluted partial current for a given signal + + Args: + signal_name (str): Name of signal for which deconvolution is to + be carried out. + kernel_obj (Kernel): Kernel object which contains the mass transport + parameters + tspan (list): Timespan for which the partial current is returned. + t_bg (list): Timespan that corresponds to the background signal. + snr (int): signal-to-noise ratio used for Wiener deconvolution. + """ + + + t_sig, v_sig = self.get_calib_signal(signal_name, + tspan=tspan, + t_bg=t_bg) + + kernel = kernel_obj.generate_kernel(dt=t_sig[1]-t_sig[0]) + kernel = np.hstack((kernel, np.zeros(len(sig) - len(kernel)))) + H = fft(kernel) + + partial_current = np.real(ifft( + fft(v_sig)*np.conj(H)/(H*np.conj(H) + (1/snr)**2) + )) + + return t_sig, partial_current + + + def extract_kernel(self, signal_name, cutoff_pot=0, tspan=None, t_bg=None): + """Extracts a Kernel object from a measurement. + + Args: + signal_name (str): Signal name from which the kernel/impule + response is to be extracted. + cutoff_pot (int): Potential which the defines the onset of the + impulse. Must be larger than the resting potential before the + impulse. + tspan(list): Timespan from which the kernel/impulse response is + extracted. + t_bg (list): Timespan that corresponds to the background signal. + """ + x_curr, y_curr = self.get_current(tspan=tspan) + x_pot, y_pot = self.get_potential(tspan=tspan) + x_sig, y_sig = self.get_signal(signal_name, tspan=tspan, t_bg=t_bg) + + if signal_name == 'M32': + t0 = x_curr[np.argmax(y_pot > cutoff_pot)] #time of impulse + elif signal_name == 'M2': + t0 = x_curr[np.argmax(y_pot < cutoff_pot)] + else: + print('mass not found') + + x_sig = x_sig - t0 + + y_sig = y_sig[x_sig>0] + x_sig = x_sig[x_sig>0] + + y_curr = y_curr[x_curr>t0] + x_curr = x_curr[x_curr>t0] + y_pot = y_pot[x_pot>t0] + x_pot = x_pot[x_pot>t0] + + kernel = Kernel( + MS_data = np.array([x_sig, y_sig]), + EC_data = np.array([x_curr, y_curr, x_pot, y_pot]) + ) + + + return kernel + + + +class Kernel: + """ + Kernel class implementing different functionalities for datatreatment of + kernel/impulse response data. + """ + def __init__ ( + self, + parameters = {}, + MS_data = None, + EC_data = None, + ): + """Initializes a Kernel object either in functional form by defining the + mass transport parameters or in the measured form by passing of EC-MS + data. + + Args: + parameters (dict): Dictionary containing the mass transport + parameters with the following keys: + D: Diffusion constant in liquid + L: Working distance between electrode and gas/liq interface + V: Gas sampling volume of the chip + V_dot: Volumetric capillary flow + kH: Dimensionless Henry volatility + MS_data (list): List of numpy arrays containing the MS signal + data. + EC_data (list): List of numpy arrays containing the EC (time, + current, potential). + """ + + if MS_data is not None and parameters: + raise Exception( + 'Kernel can only be initialized with data OR parameters, not both' + ) + if EC_data is not None and MS_data is not None: + print('Generating kernel from measured data') + self.type = 'measured' + elif parameters: + print('Generating kernel from parameters') + self.type = 'functional' + else: + print('Generating blank kernel') + self.type = None + + self.params = parameters + self.MS_data = MS_data + self.EC_data = EC_data #x_curr, y_curr, x_pot, y_pot + + @property + def sig_area(self): + """Integrates a measured impulse response and returns the area.""" + delta_sig = self.MS_data[1] - self.MS_data[1][-1] + sig_area = np.trapz(delta_sig, self.MS_data[0]) + + return sig_area + + + @property + def charge(self): + """Integrates the measured current over the time of the impulse and returns + the charge passed.""" + y_curr = self.EC_data[1] + + mask = np.isclose( + y_curr, + y_curr[0], + rtol=1e-1 + ) + + Q = np.trapz(y_curr[mask], self.EC_data[0][mask]) + + return Q + + def plot(self, + time=None, + ax = None, + norm=True, + **kwargs + ): + """Returns a plot of the kernel/impulse response.""" + if ax is None: + fig1 = plt.figure() + ax = fig1.add_subplot(111) + + if self.type is 'functional': + ax.plot( + time, self.gen_kernel(time=time, norm=norm), + **kwargs, + ) + + elif self.type is 'measured': + ax.plot( + self.MS_data[0], self.gen_kernel(norm=norm), + **kwargs, + ) + + else: + raise Exception('Nothing to plot with blank kernel') + + + return ax + + def gen_kernel(self, + dt=0.1, + len=100 + norm=True, + matrix=False + ): + """Generates a kernel/impulse response based on the defined mass transport + parameters contained in dict. + + Args: + dt (int): Timestep for which the kernel/impulse response is calculated. + len(int): Timelength in seconds for which the kernel/impulse response is + calculated. + norm (bool): If true the kernel/impulse response is normalized to its + area. + matrix (bool): If true the circulant matrix constructed from the kernel/ + impulse reponse is returned. + """ + if self.type is 'functional': + + time = np.arange(0,len,dt) + time[0] = 1e-6 + + D = self.params['D'] + L = self.params['L'] + V = self.params['V'] + V_dot = self.params['V_dot'] + kH = self.params['kH'] + + tdiff = time * D / (L**2) + fs = lambda s: 1/( + sqrt(s) * sinh(sqrt(s)) + + (V * kH / 0.196e-4 / L) + * (s + V_dot / V * L**2 / D) + * cosh(sqrt(s)) + ) + + kernel = np.zeros(len(time)) + for i in range(len(time)): + kernel[i] = invertlaplace(fs, tdiff[i], method='talbot') + + elif self.type is 'measured': + kernel = self.MS_data[1] + time = self.MS_data[0] + + if norm: + area = np.trapz(kernel, time) + kernel = kernel/area + + if matrix: + kernel = np.tile(kernel, (len(kernel), 1)) + i = 1 + while i < len(time): + kernel[i] = np.concatenate((kernel[0][i:], kernel[0][:i])) + i=i+1 + + return kernel diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index 5ab79cb9..f4ef7ea5 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -6,11 +6,24 @@ class ECMeasurement(Measurement): """Class implementing raw electrochemistry measurements""" - def __init__(self, ec_technique=None, **kwargs): - super().__init__(**kwargs) + def __init__(self, name, ec_technique=None, **kwargs): + super().__init__(name, **kwargs) self.ec_technique = ec_technique self.E_str = "Ewe/V" self.I_str = "I/mA" def get_potential(self, tspan=None): + """Returns measured electrochemical potential + + Args: + tspan (list): Timespan for which the potential is returned. + """ return self.get_t_and_v(self.E_str, tspan=tspan) + + def get_current(self, tspan=None): + """Returns measured electrochemical current + + Args: + tspan (list): Timespan for which the current is returned. + """ + return self.get_t_and_v(self.I_str, tspan=tspan) diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index 8b7efef2..e3a5e9f3 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -6,3 +6,6 @@ class ECMSMeasurement(ECMeasurement, MSMeasurement): """Class implementing raw EC-MS functionality""" + + def __init__(self, name, **kwargs): + super().__init__(name, **kwargs) diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index 104bd584..cecbaed7 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -1,7 +1,57 @@ """Module for representation and analysis of MS measurements""" from ..measurements import Measurement +import numpy as np class MSMeasurement(Measurement): """Class implementing raw MS functionality""" + + def __init__(self, name, **kwargs): + """Initializes a MS Measurement + + Args: + name (str): The name of the measurement + calibration (dict): calibration constants whereby the key + corresponds to the respective signal name.""" + super().__init__(name, **kwargs) + self.calibration=None + + + def get_signal(self, signal_name, tspan=None, t_bg=None): + """Returns raw signal for a given signal name + + Args: + signal_name (str): Name of the signal. + tspan (list): Timespan for which the signal is returned. + t_bg (list): Timespan that corresponds to the background signal. + If not given, no background is subtracted. + """ + time, value = self.get_t_and_v(signal_name, tspan=tspan) + + if t_bg is None: + return time, value + + else: + _, bg = self.get_t_and_v(signal_name, tspan=t_bg) + return time, value - np.average(bg) + + + + def get_calib_signal(self, signal_name, tspan=None, t_bg=None): + """Returns a calibrated signal for a given signal name. Only works if + calibration dict is not None. + + Args: + signal_name (str): Name of the signal. + tspan (list): Timespan for which the signal is returned. + t_bg (list): Timespan that corresponds to the background signal. + If not given, no background is subtracted. + """ + if self.calibration is None: + print('No calibration dict found.') + return + + time, value = self.get_signal(signal_name, tspan=tspan, t_bg=t_bg) + + return time, value * self.calibration[signal_name] From 14a15d2b0f1d015cd321765d694eefd37e536132 Mon Sep 17 00:00:00 2001 From: kkrempl Date: Mon, 25 Jan 2021 16:42:20 +0100 Subject: [PATCH 006/118] Syntax correction of comments --- src/ixdat/techniques/deconvolution.py | 35 ++++++++++++++------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/ixdat/techniques/deconvolution.py b/src/ixdat/techniques/deconvolution.py index 48c281cf..3cf50476 100644 --- a/src/ixdat/techniques/deconvolution.py +++ b/src/ixdat/techniques/deconvolution.py @@ -142,7 +142,7 @@ def __init__ ( @property def sig_area(self): - """Integrates a measured impulse response and returns the area.""" + """Integrates a measured impulse response and returns the area.""" delta_sig = self.MS_data[1] - self.MS_data[1][-1] sig_area = np.trapz(delta_sig, self.MS_data[0]) @@ -151,8 +151,9 @@ def sig_area(self): @property def charge(self): - """Integrates the measured current over the time of the impulse and returns - the charge passed.""" + """Integrates the measured current over the time of the impulse and returns + the charge passed. + """ y_curr = self.EC_data[1] mask = np.isclose( @@ -171,7 +172,7 @@ def plot(self, norm=True, **kwargs ): - """Returns a plot of the kernel/impulse response.""" + """Returns a plot of the kernel/impulse response.""" if ax is None: fig1 = plt.figure() ax = fig1.add_subplot(111) @@ -196,22 +197,22 @@ def plot(self, def gen_kernel(self, dt=0.1, - len=100 + len=100, norm=True, matrix=False ): - """Generates a kernel/impulse response based on the defined mass transport - parameters contained in dict. - - Args: - dt (int): Timestep for which the kernel/impulse response is calculated. - len(int): Timelength in seconds for which the kernel/impulse response is - calculated. - norm (bool): If true the kernel/impulse response is normalized to its - area. - matrix (bool): If true the circulant matrix constructed from the kernel/ - impulse reponse is returned. - """ + """Generates a kernel/impulse response based on the defined mass + transport parameters contained in dict. + + Args: + dt (int): Timestep for which the kernel/impulse response is calculated. + len(int): Timelength in seconds for which the kernel/impulse response is + calculated. + norm (bool): If true the kernel/impulse response is normalized to its + area. + matrix (bool): If true the circulant matrix constructed from the kernel/ + impulse reponse is returned. + """ if self.type is 'functional': time = np.arange(0,len,dt) From 2142121cdda4c2ddfb3765d2eb08340ce04cbd68 Mon Sep 17 00:00:00 2001 From: kkrempl Date: Tue, 26 Jan 2021 21:11:55 +0100 Subject: [PATCH 007/118] Better formatting, added TODOs, smaller bug fixes --- src/ixdat/readers/ec_ms.py | 73 ---------- src/ixdat/readers/ec_ms_pkl.py | 67 +++++++++ src/ixdat/techniques/deconvolution.py | 193 ++++++++++++-------------- src/ixdat/techniques/ec.py | 4 + src/ixdat/techniques/ms.py | 8 +- 5 files changed, 160 insertions(+), 185 deletions(-) delete mode 100644 src/ixdat/readers/ec_ms.py create mode 100644 src/ixdat/readers/ec_ms_pkl.py diff --git a/src/ixdat/readers/ec_ms.py b/src/ixdat/readers/ec_ms.py deleted file mode 100644 index fc0e037c..00000000 --- a/src/ixdat/readers/ec_ms.py +++ /dev/null @@ -1,73 +0,0 @@ -from . import TECHNIQUE_CLASSES -import pickle -from ..data_series import TimeSeries, ValueSeries -from ..measurements import Measurement -from ..techniques.ec_ms import ECMSMeasurement - - -ECMSMeasruement = TECHNIQUE_CLASSES["EC-MS"] - - -class EC_MS_CONVERTER: -"""Class that converts old .pkl files obtained from the legacy EC-MS package -into a ixdat ECMSMeasurement object. Metadata from the .pkl is omitted. -""" - def __init__(self): - print('Reader of old ECMS .pkl files') - - def read(self, file_path): - """Return an ECMSMeasurement with the data recorded in path_to_file - - This loops through the keys of the EC-MS dict and searches for MS and - EC data. Names the dataseries according to their names in the original - dict. Omitts any other data as well as metadata. - - Args: - path_to_file (Path): The full abs or rel path including the - ".pkl" extension. - """ - with open(file_path, "rb") as f: - data = pickle.load(f) - - cols_str = data['data_cols'] - cols_list = [] - - for col in cols_str: - if col[0] == "M" and col[-1] == "x": - cols_list.append(TimeSeries( - col, "s", - data[col], data["tstamp"] - )) - - if col == "time/s": - cols_list.append(TimeSeries( - col, "s", - data[col], data["tstamp"] - )) - - measurement = Measurement('tseries_ms', - technique = 'EC_MS', - series_list=cols_list) - - - for col in cols_str: - if col[0] == "M" and col[-1] == "y": - cols_list.append(ValueSeries( - col[:-2], "A", - data[col], - tseries=measurement[col[:-1] + 'x'] - )) - if col == 'Ewe/V' or col == 'I/mA': - cols_list.append(ValueSeries( - col, "A", - data[col], - tseries=measurement['time/s'] - )) - - measurement = ECMSMeasurement(file_path, - technique = 'EC_MS', - series_list=cols_list, - reader=self, - tstamp=data["tstamp"]) - - return measurement diff --git a/src/ixdat/readers/ec_ms_pkl.py b/src/ixdat/readers/ec_ms_pkl.py new file mode 100644 index 00000000..d1833053 --- /dev/null +++ b/src/ixdat/readers/ec_ms_pkl.py @@ -0,0 +1,67 @@ +from . import TECHNIQUE_CLASSES +import pickle +from ..data_series import TimeSeries, ValueSeries +from ..measurements import Measurement +from ..techniques.ec_ms import ECMSMeasurement + + +ECMSMeasruement = TECHNIQUE_CLASSES["EC-MS"] + + +class EC_MS_CONVERTER: + """Imports old .pkl files obtained from the legacy EC-MS package""" + + def __init__(self): + print("Reader of old ECMS .pkl files") + + def read(self, file_path): + """Return an ECMSMeasurement with the data recorded in path_to_file + + This loops through the keys of the EC-MS dict and searches for MS and + EC data. Names the dataseries according to their names in the original + dict. Omitts any other data as well as metadata. + + Args: + path_to_file (Path): The full abs or rel path including the + ".pkl" extension. + """ + with open(file_path, "rb") as f: + data = pickle.load(f) + + cols_str = data["data_cols"] + cols_list = [] + + for col in cols_str: + if endswith("-x"): + cols_list.append(TimeSeries(col, "s", data[col], data["tstamp"])) + + if col == "time/s": + cols_list.append(TimeSeries(col, "s", data[col], data["tstamp"])) + + measurement = Measurement( + "tseries_ms", technique="EC_MS", series_list=cols_list + ) + + for col in cols_str: + if startswith("M") and endswith("-y"): + cols_list.append( + ValueSeries( + col[:-2], "A", data[col], tseries=measurement[col[:-1] + "x"] + ) + ) + + # TODO: Import all EC data. + if col == "Ewe/V" or col == "I/mA": + cols_list.append( + ValueSeries(col, "A", data[col], tseries=measurement["time/s"]) + ) + + measurement = ECMSMeasurement( + file_path, + technique="EC_MS", + series_list=cols_list, + reader=self, + tstamp=data["tstamp"], + ) + + return measurement diff --git a/src/ixdat/techniques/deconvolution.py b/src/ixdat/techniques/deconvolution.py index 3cf50476..da740a93 100644 --- a/src/ixdat/techniques/deconvolution.py +++ b/src/ixdat/techniques/deconvolution.py @@ -2,7 +2,12 @@ deconvolution of mass transport effects to obtain partial current densities.""" from .ec_ms import ECMSMeasurement - +from scipy.optimize import curve_fit +from scipy.interpolate import interp1d +from scipy import signal +from mpmath import invertlaplace, sinh, cosh, sqrt, exp, erfc, pi, tanh, coth +import matplotlib.pyplot as plt +from numpy.fft import fft, ifft, ifftshift, fftfreq class DecoMeasurement(ECMSMeasurement): @@ -15,13 +20,10 @@ def __intit__(self, name, **kwargs): name (str): The name of the measurement""" super().__init__(name, **kwargs) - def get_partial_current(self, - signal_name, - kernel_obj, - tspan=None, - t_bg=None, - snr=10): - """Returns the deconvoluted partial current for a given signal + def get_partial_current( + self, signal_name, kernel_obj, tspan=None, t_bg=None, snr=10 + ): + """Return the deconvoluted partial current for a given signal Args: signal_name (str): Name of signal for which deconvolution is to @@ -31,24 +33,20 @@ def get_partial_current(self, tspan (list): Timespan for which the partial current is returned. t_bg (list): Timespan that corresponds to the background signal. snr (int): signal-to-noise ratio used for Wiener deconvolution. - """ - + """ - t_sig, v_sig = self.get_calib_signal(signal_name, - tspan=tspan, - t_bg=t_bg) + t_sig, v_sig = self.get_calib_signal(signal_name, tspan=tspan, t_bg=t_bg) - kernel = kernel_obj.generate_kernel(dt=t_sig[1]-t_sig[0]) + kernel = kernel_obj.generate_kernel(dt=t_sig[1] - t_sig[0]) kernel = np.hstack((kernel, np.zeros(len(sig) - len(kernel)))) H = fft(kernel) - partial_current = np.real(ifft( - fft(v_sig)*np.conj(H)/(H*np.conj(H) + (1/snr)**2) - )) + partial_current = np.real( + ifft(fft(v_sig) * np.conj(H) / (H * np.conj(H) + (1 / snr) ** 2)) + ) return t_sig, partial_current - def extract_kernel(self, signal_name, cutoff_pot=0, tspan=None, t_bg=None): """Extracts a Kernel object from a measurement. @@ -66,43 +64,40 @@ def extract_kernel(self, signal_name, cutoff_pot=0, tspan=None, t_bg=None): x_pot, y_pot = self.get_potential(tspan=tspan) x_sig, y_sig = self.get_signal(signal_name, tspan=tspan, t_bg=t_bg) - if signal_name == 'M32': - t0 = x_curr[np.argmax(y_pot > cutoff_pot)] #time of impulse - elif signal_name == 'M2': + if signal_name == "M32": + t0 = x_curr[np.argmax(y_pot > cutoff_pot)] # time of impulse + elif signal_name == "M2": t0 = x_curr[np.argmax(y_pot < cutoff_pot)] else: - print('mass not found') + print("mass not found") x_sig = x_sig - t0 - y_sig = y_sig[x_sig>0] - x_sig = x_sig[x_sig>0] + y_sig = y_sig[x_sig > 0] + x_sig = x_sig[x_sig > 0] - y_curr = y_curr[x_curr>t0] - x_curr = x_curr[x_curr>t0] - y_pot = y_pot[x_pot>t0] - x_pot = x_pot[x_pot>t0] + y_curr = y_curr[x_curr > t0] + x_curr = x_curr[x_curr > t0] + y_pot = y_pot[x_pot > t0] + x_pot = x_pot[x_pot > t0] kernel = Kernel( - MS_data = np.array([x_sig, y_sig]), - EC_data = np.array([x_curr, y_curr, x_pot, y_pot]) - ) - + MS_data=np.array([x_sig, y_sig]), + EC_data=np.array([x_curr, y_curr, x_pot, y_pot]), + ) return kernel - class Kernel: - """ - Kernel class implementing different functionalities for datatreatment of - kernel/impulse response data. - """ - def __init__ ( + """Kernel class implementing datatreatment of kernel/impulse response data.""" + + # TODO: Make class inherit from Measurement, add properties to store kernel + def __init__( self, - parameters = {}, - MS_data = None, - EC_data = None, + parameters={}, + MS_data=None, + EC_data=None, ): """Initializes a Kernel object either in functional form by defining the mass transport parameters or in the measured form by passing of EC-MS @@ -111,11 +106,11 @@ def __init__ ( Args: parameters (dict): Dictionary containing the mass transport parameters with the following keys: - D: Diffusion constant in liquid - L: Working distance between electrode and gas/liq interface - V: Gas sampling volume of the chip - V_dot: Volumetric capillary flow - kH: Dimensionless Henry volatility + diff_const: Diffusion constant in liquid + work_dist: Working distance between electrode and gas/liq interface + vol_gas: Gas sampling volume of the chip + volflow_cap: Volumetric capillary flow + henry_vol: Dimensionless Henry volatility MS_data (list): List of numpy arrays containing the MS signal data. EC_data (list): List of numpy arrays containing the EC (time, @@ -124,21 +119,21 @@ def __init__ ( if MS_data is not None and parameters: raise Exception( - 'Kernel can only be initialized with data OR parameters, not both' - ) + "Kernel can only be initialized with data OR parameters, not both" + ) if EC_data is not None and MS_data is not None: - print('Generating kernel from measured data') - self.type = 'measured' + print("Generating kernel from measured data") + self.type = "measured" elif parameters: - print('Generating kernel from parameters') - self.type = 'functional' + print("Generating kernel from parameters") + self.type = "functional" else: - print('Generating blank kernel') + print("Generating blank kernel") self.type = None self.params = parameters self.MS_data = MS_data - self.EC_data = EC_data #x_curr, y_curr, x_pot, y_pot + self.EC_data = EC_data # x_curr, y_curr, x_pot, y_pot @property def sig_area(self): @@ -148,64 +143,48 @@ def sig_area(self): return sig_area - @property def charge(self): - """Integrates the measured current over the time of the impulse and returns - the charge passed. - """ + """Integrates the measured current over the time.""" y_curr = self.EC_data[1] - mask = np.isclose( - y_curr, - y_curr[0], - rtol=1e-1 - ) + mask = np.isclose(y_curr, y_curr[0], rtol=1e-1) Q = np.trapz(y_curr[mask], self.EC_data[0][mask]) return Q - def plot(self, - time=None, - ax = None, - norm=True, - **kwargs - ): + def plot(self, t_kernel=None, ax=None, norm=True, **kwargs): """Returns a plot of the kernel/impulse response.""" if ax is None: fig1 = plt.figure() ax = fig1.add_subplot(111) - if self.type is 'functional': + if self.type is "functional": ax.plot( - time, self.gen_kernel(time=time, norm=norm), + t_kernel, + self.gen_kernel(t_kernel=t_kernel, norm=norm), **kwargs, - ) + ) - elif self.type is 'measured': + elif self.type is "measured": ax.plot( - self.MS_data[0], self.gen_kernel(norm=norm), - **kwargs, + self.MS_data[0], + self.gen_kernel(norm=norm), + **kwargs, ) else: - raise Exception('Nothing to plot with blank kernel') - + raise Exception("Nothing to plot with blank kernel") return ax - def gen_kernel(self, - dt=0.1, - len=100, - norm=True, - matrix=False - ): - """Generates a kernel/impulse response based on the defined mass - transport parameters contained in dict. + def calculate_kernel(self, dt=0.1, len=100, norm=True, matrix=False): + """Calculates a kernel/impulse response. Args: dt (int): Timestep for which the kernel/impulse response is calculated. + Has to match the timestep of the measured data for deconvolution. len(int): Timelength in seconds for which the kernel/impulse response is calculated. norm (bool): If true the kernel/impulse response is normalized to its @@ -213,42 +192,42 @@ def gen_kernel(self, matrix (bool): If true the circulant matrix constructed from the kernel/ impulse reponse is returned. """ - if self.type is 'functional': + if self.type is "functional": - time = np.arange(0,len,dt) - time[0] = 1e-6 + t_kernel = np.arange(0, len, dt) + t_kernel[0] = 1e-6 - D = self.params['D'] - L = self.params['L'] - V = self.params['V'] - V_dot = self.params['V_dot'] - kH = self.params['kH'] + diff_const = self.params["diff_const"] + work_dist = self.params["work_dist"] + vol_gas = self.params["vol_gas"] + volflow_cap = self.params["volflow_cap"] + henry_vol = self.params["henry_vol"] - tdiff = time * D / (L**2) - fs = lambda s: 1/( + tdiff = t_kernel * diff_const / (work_dist ** 2) + fs = lambda s: 1 / ( sqrt(s) * sinh(sqrt(s)) - + (V * kH / 0.196e-4 / L) - * (s + V_dot / V * L**2 / D) + + (vol_gas * henry_vol / 0.196e-4 / work_dist) + * (s + volflow_cap / vol_gas * work_dist ** 2 / diff_const) * cosh(sqrt(s)) - ) + ) - kernel = np.zeros(len(time)) - for i in range(len(time)): - kernel[i] = invertlaplace(fs, tdiff[i], method='talbot') + kernel = np.zeros(len(t_kernel)) + for i in range(len(t_kernel)): + kernel[i] = invertlaplace(fs, tdiff[i], method="talbot") - elif self.type is 'measured': + elif self.type is "measured": kernel = self.MS_data[1] - time = self.MS_data[0] + t_kernel = self.MS_data[0] if norm: - area = np.trapz(kernel, time) - kernel = kernel/area + area = np.trapz(kernel, t_kernel) + kernel = kernel / area if matrix: kernel = np.tile(kernel, (len(kernel), 1)) i = 1 - while i < len(time): + while i < len(t_kernel): kernel[i] = np.concatenate((kernel[0][i:], kernel[0][:i])) - i=i+1 + i = i + 1 return kernel diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index f4ef7ea5..9529ea65 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -7,6 +7,10 @@ class ECMeasurement(Measurement): """Class implementing raw electrochemistry measurements""" def __init__(self, name, ec_technique=None, **kwargs): + """initialize a EC measurement + + Args: + name (str): The name of the measurement""" super().__init__(name, **kwargs) self.ec_technique = ec_technique self.E_str = "Ewe/V" diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index cecbaed7..98a65bd6 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -15,8 +15,7 @@ def __init__(self, name, **kwargs): calibration (dict): calibration constants whereby the key corresponds to the respective signal name.""" super().__init__(name, **kwargs) - self.calibration=None - + self.calibration = None # TODO: Not final implementation def get_signal(self, signal_name, tspan=None, t_bg=None): """Returns raw signal for a given signal name @@ -36,8 +35,6 @@ def get_signal(self, signal_name, tspan=None, t_bg=None): _, bg = self.get_t_and_v(signal_name, tspan=t_bg) return time, value - np.average(bg) - - def get_calib_signal(self, signal_name, tspan=None, t_bg=None): """Returns a calibrated signal for a given signal name. Only works if calibration dict is not None. @@ -48,8 +45,9 @@ def get_calib_signal(self, signal_name, tspan=None, t_bg=None): t_bg (list): Timespan that corresponds to the background signal. If not given, no background is subtracted. """ + # TODO: Not final implementation if self.calibration is None: - print('No calibration dict found.') + print("No calibration dict found.") return time, value = self.get_signal(signal_name, tspan=tspan, t_bg=t_bg) From 2dd8a52bfbe53ea9d127099568a0798d07b32711 Mon Sep 17 00:00:00 2001 From: kkrempl Date: Tue, 26 Jan 2021 21:30:27 +0100 Subject: [PATCH 008/118] More TODOs --- src/ixdat/techniques/deconvolution.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ixdat/techniques/deconvolution.py b/src/ixdat/techniques/deconvolution.py index da740a93..52a86907 100644 --- a/src/ixdat/techniques/deconvolution.py +++ b/src/ixdat/techniques/deconvolution.py @@ -93,6 +93,7 @@ class Kernel: """Kernel class implementing datatreatment of kernel/impulse response data.""" # TODO: Make class inherit from Measurement, add properties to store kernel + # TODO: Reference equations to paper. def __init__( self, parameters={}, From d6a41665ec52887590631b7e1520e55543a4d557 Mon Sep 17 00:00:00 2001 From: kkrempl Date: Thu, 28 Jan 2021 12:53:57 +0100 Subject: [PATCH 009/118] Even more TODOs --- src/ixdat/techniques/deconvolution.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ixdat/techniques/deconvolution.py b/src/ixdat/techniques/deconvolution.py index 52a86907..dbd10057 100644 --- a/src/ixdat/techniques/deconvolution.py +++ b/src/ixdat/techniques/deconvolution.py @@ -40,7 +40,7 @@ def get_partial_current( kernel = kernel_obj.generate_kernel(dt=t_sig[1] - t_sig[0]) kernel = np.hstack((kernel, np.zeros(len(sig) - len(kernel)))) H = fft(kernel) - + # TODO: store this as well. partial_current = np.real( ifft(fft(v_sig) * np.conj(H) / (H * np.conj(H) + (1 / snr) ** 2)) ) @@ -118,7 +118,7 @@ def __init__( current, potential). """ - if MS_data is not None and parameters: + if MS_data is not None and parameters: # TODO: Make two different classes raise Exception( "Kernel can only be initialized with data OR parameters, not both" ) @@ -187,7 +187,7 @@ def calculate_kernel(self, dt=0.1, len=100, norm=True, matrix=False): dt (int): Timestep for which the kernel/impulse response is calculated. Has to match the timestep of the measured data for deconvolution. len(int): Timelength in seconds for which the kernel/impulse response is - calculated. + calculated. Must be long enough to reach zero. norm (bool): If true the kernel/impulse response is normalized to its area. matrix (bool): If true the circulant matrix constructed from the kernel/ From e0e6e8abf915e8ff3ea496e1dcdf94291d420dde Mon Sep 17 00:00:00 2001 From: kkrempl Date: Sun, 31 Jan 2021 17:27:39 +0100 Subject: [PATCH 010/118] small bug fixes --- src/ixdat/readers/__init__.py | 2 +- src/ixdat/readers/ec_ms_pkl.py | 4 ++-- src/ixdat/techniques/deconvolution.py | 20 +++++++++++--------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/ixdat/readers/__init__.py b/src/ixdat/readers/__init__.py index 5b1215fa..653a6fbf 100644 --- a/src/ixdat/readers/__init__.py +++ b/src/ixdat/readers/__init__.py @@ -6,7 +6,7 @@ is the reader class for parsing files. """ from ..techniques import TECHNIQUE_CLASSES -from .ec_ms import EC_MS_CONVERTER +from .ec_ms_pkl import EC_MS_CONVERTER from .zilien import ZilienTSVReader from .biologic import BiologicMPTReader diff --git a/src/ixdat/readers/ec_ms_pkl.py b/src/ixdat/readers/ec_ms_pkl.py index d1833053..9398d88d 100644 --- a/src/ixdat/readers/ec_ms_pkl.py +++ b/src/ixdat/readers/ec_ms_pkl.py @@ -32,7 +32,7 @@ def read(self, file_path): cols_list = [] for col in cols_str: - if endswith("-x"): + if col.endswith("-x"): cols_list.append(TimeSeries(col, "s", data[col], data["tstamp"])) if col == "time/s": @@ -43,7 +43,7 @@ def read(self, file_path): ) for col in cols_str: - if startswith("M") and endswith("-y"): + if col.startswith("M") and col.endswith("-y"): cols_list.append( ValueSeries( col[:-2], "A", data[col], tseries=measurement[col[:-1] + "x"] diff --git a/src/ixdat/techniques/deconvolution.py b/src/ixdat/techniques/deconvolution.py index dbd10057..cfa85a47 100644 --- a/src/ixdat/techniques/deconvolution.py +++ b/src/ixdat/techniques/deconvolution.py @@ -8,6 +8,7 @@ from mpmath import invertlaplace, sinh, cosh, sqrt, exp, erfc, pi, tanh, coth import matplotlib.pyplot as plt from numpy.fft import fft, ifft, ifftshift, fftfreq +import numpy as np class DecoMeasurement(ECMSMeasurement): @@ -111,7 +112,7 @@ def __init__( work_dist: Working distance between electrode and gas/liq interface vol_gas: Gas sampling volume of the chip volflow_cap: Volumetric capillary flow - henry_vol: Dimensionless Henry volatility + henry_vola: Dimensionless Henry volatility MS_data (list): List of numpy arrays containing the MS signal data. EC_data (list): List of numpy arrays containing the EC (time, @@ -155,23 +156,24 @@ def charge(self): return Q - def plot(self, t_kernel=None, ax=None, norm=True, **kwargs): + def plot(self, dt=0.1, duration=100, ax=None, norm=True, **kwargs): """Returns a plot of the kernel/impulse response.""" if ax is None: fig1 = plt.figure() ax = fig1.add_subplot(111) if self.type is "functional": + t_kernel = np.arange(0, duration, dt) ax.plot( t_kernel, - self.gen_kernel(t_kernel=t_kernel, norm=norm), + self.calculate_kernel(dt=dt, duration=duration, norm=norm), **kwargs, ) elif self.type is "measured": ax.plot( self.MS_data[0], - self.gen_kernel(norm=norm), + self.calculate_kernel(dt=dt, duration=duration, norm=norm), **kwargs, ) @@ -180,13 +182,13 @@ def plot(self, t_kernel=None, ax=None, norm=True, **kwargs): return ax - def calculate_kernel(self, dt=0.1, len=100, norm=True, matrix=False): + def calculate_kernel(self, dt=0.1, duration=100, norm=True, matrix=False): """Calculates a kernel/impulse response. Args: dt (int): Timestep for which the kernel/impulse response is calculated. Has to match the timestep of the measured data for deconvolution. - len(int): Timelength in seconds for which the kernel/impulse response is + duration(int): Duration in seconds for which the kernel/impulse response is calculated. Must be long enough to reach zero. norm (bool): If true the kernel/impulse response is normalized to its area. @@ -195,19 +197,19 @@ def calculate_kernel(self, dt=0.1, len=100, norm=True, matrix=False): """ if self.type is "functional": - t_kernel = np.arange(0, len, dt) + t_kernel = np.arange(0, duration, dt) t_kernel[0] = 1e-6 diff_const = self.params["diff_const"] work_dist = self.params["work_dist"] vol_gas = self.params["vol_gas"] volflow_cap = self.params["volflow_cap"] - henry_vol = self.params["henry_vol"] + henry_vola = self.params["henry_vola"] tdiff = t_kernel * diff_const / (work_dist ** 2) fs = lambda s: 1 / ( sqrt(s) * sinh(sqrt(s)) - + (vol_gas * henry_vol / 0.196e-4 / work_dist) + + (vol_gas * henry_vola / 0.196e-4 / work_dist) * (s + volflow_cap / vol_gas * work_dist ** 2 / diff_const) * cosh(sqrt(s)) ) From ac2e8244cb95f264321d715fbf9ec8dfbe6c92b6 Mon Sep 17 00:00:00 2001 From: kkrempl Date: Tue, 2 Feb 2021 14:47:29 +0100 Subject: [PATCH 011/118] bug fixes, now able to run scripts for paper --- src/ixdat/readers/ec_ms_pkl.py | 2 +- src/ixdat/techniques/deconvolution.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ixdat/readers/ec_ms_pkl.py b/src/ixdat/readers/ec_ms_pkl.py index 9398d88d..8c8bd938 100644 --- a/src/ixdat/readers/ec_ms_pkl.py +++ b/src/ixdat/readers/ec_ms_pkl.py @@ -16,7 +16,7 @@ def __init__(self): def read(self, file_path): """Return an ECMSMeasurement with the data recorded in path_to_file - + # TODO: Always returns ECMS class even when read with DecoMeasurement.read() This loops through the keys of the EC-MS dict and searches for MS and EC data. Names the dataseries according to their names in the original dict. Omitts any other data as well as metadata. diff --git a/src/ixdat/techniques/deconvolution.py b/src/ixdat/techniques/deconvolution.py index cfa85a47..98d227b6 100644 --- a/src/ixdat/techniques/deconvolution.py +++ b/src/ixdat/techniques/deconvolution.py @@ -38,14 +38,14 @@ def get_partial_current( t_sig, v_sig = self.get_calib_signal(signal_name, tspan=tspan, t_bg=t_bg) - kernel = kernel_obj.generate_kernel(dt=t_sig[1] - t_sig[0]) - kernel = np.hstack((kernel, np.zeros(len(sig) - len(kernel)))) + kernel = kernel_obj.calculate_kernel(dt=t_sig[1] - t_sig[0]) + kernel = np.hstack((kernel, np.zeros(len(v_sig) - len(kernel)))) H = fft(kernel) # TODO: store this as well. partial_current = np.real( ifft(fft(v_sig) * np.conj(H) / (H * np.conj(H) + (1 / snr) ** 2)) ) - + partial_current = partial_current * sum(kernel) return t_sig, partial_current def extract_kernel(self, signal_name, cutoff_pot=0, tspan=None, t_bg=None): From e4bc226adb6c26ecb12275285eec5c97caad84ff Mon Sep 17 00:00:00 2001 From: kkrempl Date: Tue, 2 Feb 2021 14:50:21 +0100 Subject: [PATCH 012/118] change docstring --- src/ixdat/techniques/deconvolution.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ixdat/techniques/deconvolution.py b/src/ixdat/techniques/deconvolution.py index 98d227b6..88ac1c3d 100644 --- a/src/ixdat/techniques/deconvolution.py +++ b/src/ixdat/techniques/deconvolution.py @@ -1,5 +1,4 @@ -"""Module for analysis of EC-MS measurements with impulse response and -deconvolution of mass transport effects to obtain partial current densities.""" +"""Module for deconvolution of mass transport effects.""" from .ec_ms import ECMSMeasurement from scipy.optimize import curve_fit From 17b4990349724ee3f94b7acf4afa96eb83cfb93a Mon Sep 17 00:00:00 2001 From: kkrempl Date: Tue, 2 Feb 2021 16:01:32 +0100 Subject: [PATCH 013/118] delete merge conflicts --- src/ixdat/techniques/ec.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index 2ca12c37..0d86d6b9 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -64,14 +64,6 @@ class ECMeasurement(Measurement): - `ec_meas.v` for `ec_meas["potential"].data` - `ec_meas.j` for `ec_meas["current"].data -<<<<<<< HEAD - def __init__(self, name, ec_technique=None, **kwargs): - """initialize a EC measurement - - Args: - name (str): The name of the measurement""" - super().__init__(name, **kwargs) -======= `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()`). @@ -184,7 +176,6 @@ def __init__( lablog=lablog, tstamp=tstamp, ) ->>>>>>> 430d2b38154c1fb9fc5c100bd3a2dc875f9611eb self.ec_technique = ec_technique self.t_str = t_str self.E_str = E_str @@ -454,22 +445,6 @@ def current(self): ) def get_potential(self, tspan=None): -<<<<<<< HEAD - """Returns measured electrochemical potential - - Args: - tspan (list): Timespan for which the potential is returned. - """ - return self.get_t_and_v(self.E_str, tspan=tspan) - - def get_current(self, tspan=None): - """Returns measured electrochemical current - - Args: - tspan (list): Timespan for which the current is returned. - """ - return self.get_t_and_v(self.I_str, tspan=tspan) -======= """Return the time [s] and potential [V] vectors cut by tspan TODO: I think this is identical, now that __getitem__ finds potential, to @@ -620,4 +595,3 @@ def as_cv(self): del self_as_dict["s_ids"] # Note, this works perfectly! All needed information is in self_as_dict :) return CyclicVoltammagram.from_dict(self_as_dict) ->>>>>>> 430d2b38154c1fb9fc5c100bd3a2dc875f9611eb From 49a68a5250c6a3ce3a56744b6b56ebc5133d0b74 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Sun, 7 Feb 2021 14:08:34 +0000 Subject: [PATCH 014/118] pass cls from M.read() via R.read() to M.from_dict() --- src/ixdat/measurements.py | 11 ++++++++++- src/ixdat/readers/biologic.py | 9 +++++---- src/ixdat/readers/ec_ms_pkl.py | 9 ++++++--- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index e0e4107f..f21eb9ed 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -128,8 +128,16 @@ def from_dict(cls, obj_as_dict): del obj_as_dict[object_name_str] if obj_as_dict["technique"] in TECHNIQUE_CLASSES: + # This makes it so that from_dict() can be used to initiate for any more + # derived technique, so long as obj_as_dict specifies the technique name! technique_class = TECHNIQUE_CLASSES[obj_as_dict["technique"]] + if not issubclass(technique_class, cls): + # But we never want obj_as_dict["technique"] to take us to a *less* + # specific technique, if the user has been intentional about which + # class they call `as_dict` from (e.g. via a Reader)! + technique_class = cls else: + # Normally, we're going to want to make sure that we're in technique_class = cls try: measurement = technique_class(**obj_as_dict) @@ -146,7 +154,8 @@ def read(cls, path_to_file, reader, **kwargs): from .readers import READER_CLASSES reader = READER_CLASSES[reader]() - return reader.read(path_to_file, **kwargs) # TODO: take cls as kwarg + # print(f"{__name__}. cls={cls}") # debugging + return reader.read(path_to_file, cls=cls, **kwargs) @property def metadata_json_string(self): diff --git a/src/ixdat/readers/biologic.py b/src/ixdat/readers/biologic.py index c9078e98..84916a21 100644 --- a/src/ixdat/readers/biologic.py +++ b/src/ixdat/readers/biologic.py @@ -75,7 +75,7 @@ def __init__(self): self.file_has_been_read = False self.measurement = None - def read(self, path_to_file, name=None, **kwargs): + def read(self, path_to_file, name=None, cls=None, **kwargs): """Return an ECMeasurement with the data and metadata recorded in path_to_file This loops through the lines of the file, processing one at a time. For header @@ -100,6 +100,7 @@ def read(self, path_to_file, name=None, **kwargs): ) return self.measurement self.name = name or path_to_file.name + self.measurement_class = cls or ECMeasurement self.path_to_file = path_to_file with open(path_to_file) as f: for line in f: @@ -130,7 +131,7 @@ def read(self, path_to_file, name=None, **kwargs): ) data_series_list.append(vseries) - init_kwargs = dict( + obj_as_dict = dict( name=self.name, technique="EC", reader=self, @@ -138,9 +139,9 @@ def read(self, path_to_file, name=None, **kwargs): tstamp=self.tstamp, ec_technique=self.ec_technique, ) - init_kwargs.update(kwargs) + obj_as_dict.update(kwargs) - self.measurement = ECMeasurement(**init_kwargs) # cls.from_dict(**init_kwargs) + self.measurement = self.measurement_class.from_dict(obj_as_dict) self.file_has_been_read = True return self.measurement diff --git a/src/ixdat/readers/ec_ms_pkl.py b/src/ixdat/readers/ec_ms_pkl.py index 8c8bd938..4fa16257 100644 --- a/src/ixdat/readers/ec_ms_pkl.py +++ b/src/ixdat/readers/ec_ms_pkl.py @@ -14,7 +14,7 @@ class EC_MS_CONVERTER: def __init__(self): print("Reader of old ECMS .pkl files") - def read(self, file_path): + def read(self, file_path, cls=None): """Return an ECMSMeasurement with the data recorded in path_to_file # TODO: Always returns ECMS class even when read with DecoMeasurement.read() This loops through the keys of the EC-MS dict and searches for MS and @@ -56,12 +56,15 @@ def read(self, file_path): ValueSeries(col, "A", data[col], tseries=measurement["time/s"]) ) - measurement = ECMSMeasurement( - file_path, + obj_as_dict = dict( + name=file_path, technique="EC_MS", series_list=cols_list, reader=self, tstamp=data["tstamp"], ) + # print(f"{__name__}. cls={cls}") # debugging + + measurement = cls.from_dict(obj_as_dict) return measurement From def62449b5a0d46718b54472daacda31d4c892e0 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Sun, 7 Feb 2021 14:44:03 +0000 Subject: [PATCH 015/118] update requirements and appease flake8 --- requirements.txt | 6 ++++- src/ixdat/readers/ec_ms_pkl.py | 1 - src/ixdat/techniques/deconvolution.py | 32 ++++++++++++++------------- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/requirements.txt b/requirements.txt index e6d06cae..e7d6c5d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,6 @@ numpy>=1.16 -matplotlib>=3.2 \ No newline at end of file +matplotlib>=3.2 +EC_MS>=0.7.4 # Temporary! For Zilien reading. +scipy>=1.5 # for deconvolution (should be plugin?) +mpmath>=1 # for deconvolution (should be plugin? + diff --git a/src/ixdat/readers/ec_ms_pkl.py b/src/ixdat/readers/ec_ms_pkl.py index 4fa16257..64f9e341 100644 --- a/src/ixdat/readers/ec_ms_pkl.py +++ b/src/ixdat/readers/ec_ms_pkl.py @@ -2,7 +2,6 @@ import pickle from ..data_series import TimeSeries, ValueSeries from ..measurements import Measurement -from ..techniques.ec_ms import ECMSMeasurement ECMSMeasruement = TECHNIQUE_CLASSES["EC-MS"] diff --git a/src/ixdat/techniques/deconvolution.py b/src/ixdat/techniques/deconvolution.py index 88ac1c3d..d0b3fa13 100644 --- a/src/ixdat/techniques/deconvolution.py +++ b/src/ixdat/techniques/deconvolution.py @@ -1,12 +1,12 @@ """Module for deconvolution of mass transport effects.""" from .ec_ms import ECMSMeasurement -from scipy.optimize import curve_fit -from scipy.interpolate import interp1d -from scipy import signal -from mpmath import invertlaplace, sinh, cosh, sqrt, exp, erfc, pi, tanh, coth +from scipy.optimize import curve_fit # noqa +from scipy.interpolate import interp1d # noqa +from scipy import signal # noqa +from mpmath import invertlaplace, sinh, cosh, sqrt, exp, erfc, pi, tanh, coth # noqa import matplotlib.pyplot as plt -from numpy.fft import fft, ifft, ifftshift, fftfreq +from numpy.fft import fft, ifft, ifftshift, fftfreq # noqa import numpy as np @@ -161,7 +161,7 @@ def plot(self, dt=0.1, duration=100, ax=None, norm=True, **kwargs): fig1 = plt.figure() ax = fig1.add_subplot(111) - if self.type is "functional": + if self.type == "functional": t_kernel = np.arange(0, duration, dt) ax.plot( t_kernel, @@ -169,7 +169,7 @@ def plot(self, dt=0.1, duration=100, ax=None, norm=True, **kwargs): **kwargs, ) - elif self.type is "measured": + elif self.type == "measured": ax.plot( self.MS_data[0], self.calculate_kernel(dt=dt, duration=duration, norm=norm), @@ -194,7 +194,7 @@ def calculate_kernel(self, dt=0.1, duration=100, norm=True, matrix=False): matrix (bool): If true the circulant matrix constructed from the kernel/ impulse reponse is returned. """ - if self.type is "functional": + if self.type == "functional": t_kernel = np.arange(0, duration, dt) t_kernel[0] = 1e-6 @@ -206,18 +206,20 @@ def calculate_kernel(self, dt=0.1, duration=100, norm=True, matrix=False): henry_vola = self.params["henry_vola"] tdiff = t_kernel * diff_const / (work_dist ** 2) - fs = lambda s: 1 / ( - sqrt(s) * sinh(sqrt(s)) - + (vol_gas * henry_vola / 0.196e-4 / work_dist) - * (s + volflow_cap / vol_gas * work_dist ** 2 / diff_const) - * cosh(sqrt(s)) - ) + + def fs(s): + return 1 / ( + sqrt(s) * sinh(sqrt(s)) + + (vol_gas * henry_vola / 0.196e-4 / work_dist) + * (s + volflow_cap / vol_gas * work_dist ** 2 / diff_const) + * cosh(sqrt(s)) + ) kernel = np.zeros(len(t_kernel)) for i in range(len(t_kernel)): kernel[i] = invertlaplace(fs, tdiff[i], method="talbot") - elif self.type is "measured": + elif self.type == "measured": kernel = self.MS_data[1] t_kernel = self.MS_data[0] From 36e91c07c5ccb1f6941a131fd70b8d59a85021dd Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Sun, 7 Feb 2021 18:36:38 +0000 Subject: [PATCH 016/118] rename 'get_' methods for t, v vectors to 'grab_' --- src/ixdat/exporters/csv_exporter.py | 2 +- src/ixdat/measurements.py | 4 ++-- src/ixdat/plotters/ec_plotter.py | 8 +++---- src/ixdat/plotters/value_plotter.py | 2 +- src/ixdat/readers/biologic.py | 4 ++-- src/ixdat/techniques/deconvolution.py | 10 ++++---- src/ixdat/techniques/ec.py | 34 ++++++++++----------------- src/ixdat/techniques/ms.py | 10 ++++---- 8 files changed, 32 insertions(+), 42 deletions(-) diff --git a/src/ixdat/exporters/csv_exporter.py b/src/ixdat/exporters/csv_exporter.py index 31114fb4..5480df4a 100644 --- a/src/ixdat/exporters/csv_exporter.py +++ b/src/ixdat/exporters/csv_exporter.py @@ -41,7 +41,7 @@ def export_measurement(self, measurement, path_to_file, v_list=None, tspan=None) path_to_file = path_to_file.with_suffix(".csv") for v_name in v_list: t_name = measurement[v_name].tseries.name - t, v = measurement.get_t_and_v(v_name, tspan=tspan) + t, v = measurement.grab(v_name, tspan=tspan) if t_name not in columns_data: columns_data[t_name] = t s_list.append(t_name) diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index f21eb9ed..0e496a38 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -279,7 +279,7 @@ def __delitem__(self, series_name): new_series_list.append(s) self._series_list = new_series_list - def get_t_and_v(self, item, tspan=None): + def grab(self, item, tspan=None): """Return the time and value vectors for a given VSeries name cut by tspan""" vseries = self[item] tseries = vseries.tseries @@ -422,7 +422,7 @@ def select_value(self, *args, **kwargs): new_measurement = self ((series_name, value),) = kwargs.items() - t, v = self.get_t_and_v(series_name) + t, v = self.grab(series_name) mask = v == value # linter doesn't realize this is a np array mask_prev = np.append(False, mask[:-1]) mask_next = np.append(mask[1:], False) diff --git a/src/ixdat/plotters/ec_plotter.py b/src/ixdat/plotters/ec_plotter.py index 2156fd14..b43dbaa9 100644 --- a/src/ixdat/plotters/ec_plotter.py +++ b/src/ixdat/plotters/ec_plotter.py @@ -57,8 +57,8 @@ def plot_measurement( J_str = J_str or ( measurement.J_str if measurement.A_el is not None else measurement.I_str ) - t_v, v = measurement.get_t_and_v(V_str, tspan=tspan) - t_j, j = measurement.get_t_and_v(J_str, tspan=tspan) + t_v, v = measurement.grab(V_str, tspan=tspan) + t_j, j = measurement.grab(J_str, tspan=tspan) if axes: ax1, ax2 = axes else: @@ -111,8 +111,8 @@ def plot_vs_potential( J_str = J_str or ( measurement.J_str if measurement.A_el is not None else measurement.I_str ) - t_v, v = measurement.get_t_and_v(V_str, tspan=tspan) - t_j, j = measurement.get_t_and_v(J_str, tspan=tspan) + t_v, v = measurement.grab(V_str, tspan=tspan) + t_j, j = measurement.grab(J_str, tspan=tspan) j_v = np.interp(t_v, t_j, j) if not ax: diff --git a/src/ixdat/plotters/value_plotter.py b/src/ixdat/plotters/value_plotter.py index 0cbad3d7..7ac47754 100644 --- a/src/ixdat/plotters/value_plotter.py +++ b/src/ixdat/plotters/value_plotter.py @@ -33,7 +33,7 @@ def plot_measurement( for v_name in v_list: try: - v, t = measurement.get_t_and_v(v_name, tspan=tspan) + v, t = measurement.grab(v_name, tspan=tspan) except SeriesNotFoundError as e: print(f"WARNING!!! {e}") continue diff --git a/src/ixdat/readers/biologic.py b/src/ixdat/readers/biologic.py index 84916a21..afa24a02 100644 --- a/src/ixdat/readers/biologic.py +++ b/src/ixdat/readers/biologic.py @@ -277,10 +277,10 @@ def timestamp_string_to_tstamp(timestamp_string, form=None): path_to_file=path_to_test_file, ) - t, v = ec_measurement.get_potential(tspan=[0, 100]) + t, v = ec_measurement.grab_potential(tspan=[0, 100]) ec_measurement.tstamp -= 20 - t_shift, v_shift = ec_measurement.get_potential(tspan=[0, 100]) + t_shift, v_shift = ec_measurement.grab_potential(tspan=[0, 100]) fig, ax = plt.subplots() ax.plot(t, v, "k", label="original tstamp") diff --git a/src/ixdat/techniques/deconvolution.py b/src/ixdat/techniques/deconvolution.py index d0b3fa13..4b0cdf70 100644 --- a/src/ixdat/techniques/deconvolution.py +++ b/src/ixdat/techniques/deconvolution.py @@ -20,7 +20,7 @@ def __intit__(self, name, **kwargs): name (str): The name of the measurement""" super().__init__(name, **kwargs) - def get_partial_current( + def grab_partial_current( self, signal_name, kernel_obj, tspan=None, t_bg=None, snr=10 ): """Return the deconvoluted partial current for a given signal @@ -35,7 +35,7 @@ def get_partial_current( snr (int): signal-to-noise ratio used for Wiener deconvolution. """ - t_sig, v_sig = self.get_calib_signal(signal_name, tspan=tspan, t_bg=t_bg) + t_sig, v_sig = self.grab_cal_signal(signal_name, tspan=tspan, t_bg=t_bg) kernel = kernel_obj.calculate_kernel(dt=t_sig[1] - t_sig[0]) kernel = np.hstack((kernel, np.zeros(len(v_sig) - len(kernel)))) @@ -60,9 +60,9 @@ def extract_kernel(self, signal_name, cutoff_pot=0, tspan=None, t_bg=None): extracted. t_bg (list): Timespan that corresponds to the background signal. """ - x_curr, y_curr = self.get_current(tspan=tspan) - x_pot, y_pot = self.get_potential(tspan=tspan) - x_sig, y_sig = self.get_signal(signal_name, tspan=tspan, t_bg=t_bg) + x_curr, y_curr = self.grab_current(tspan=tspan) + x_pot, y_pot = self.grab_potential(tspan=tspan) + x_sig, y_sig = self.grab_signal(signal_name, tspan=tspan, t_bg=t_bg) if signal_name == "M32": t0 = x_curr[np.argmax(y_pot > cutoff_pot)] # time of impulse diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index 734f9166..564cbeb3 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -457,29 +457,19 @@ def current(self): tseries=raw_current.tseries, ) - def get_potential(self, tspan=None): - """Return the time [s] and potential [V] vectors cut by tspan + 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) - TODO: I think this is identical, now that __getitem__ finds potential, to - self.get_t_and_v("potential", tspan=tspan) - """ - t = self.potential.t.copy() - v = self.potential.data.copy() - if tspan: - mask = np.logical_and(tspan[0] < t, t < tspan[-1]) - t = t[mask] - v = v[mask] - return t, v - - def get_current(self, tspan=None): - """Return the time [s] and current ([mA] or [mA/cm^2]) vectors cut by tspan""" - t = self.current.t.copy() - j = self.current.data.copy() - if tspan: - mask = np.logical_and(tspan[0] < t, t < tspan[-1]) - t = t[mask] - j = j[mask] - return t, j + 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): diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index 98a65bd6..876cf8f4 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -17,7 +17,7 @@ def __init__(self, name, **kwargs): super().__init__(name, **kwargs) self.calibration = None # TODO: Not final implementation - def get_signal(self, signal_name, tspan=None, t_bg=None): + def grab_signal(self, signal_name, tspan=None, t_bg=None): """Returns raw signal for a given signal name Args: @@ -26,16 +26,16 @@ def get_signal(self, signal_name, tspan=None, t_bg=None): t_bg (list): Timespan that corresponds to the background signal. If not given, no background is subtracted. """ - time, value = self.get_t_and_v(signal_name, tspan=tspan) + time, value = self.grab(signal_name, tspan=tspan) if t_bg is None: return time, value else: - _, bg = self.get_t_and_v(signal_name, tspan=t_bg) + _, bg = self.grab(signal_name, tspan=t_bg) return time, value - np.average(bg) - def get_calib_signal(self, signal_name, tspan=None, t_bg=None): + def grab_cal_signal(self, signal_name, tspan=None, t_bg=None): """Returns a calibrated signal for a given signal name. Only works if calibration dict is not None. @@ -50,6 +50,6 @@ def get_calib_signal(self, signal_name, tspan=None, t_bg=None): print("No calibration dict found.") return - time, value = self.get_signal(signal_name, tspan=tspan, t_bg=t_bg) + time, value = self.grab_signal(signal_name, tspan=tspan, t_bg=t_bg) return time, value * self.calibration[signal_name] From 27e044d673407ab178496fb2cc0875f6fefec3b8 Mon Sep 17 00:00:00 2001 From: kkrempl Date: Mon, 15 Feb 2021 07:12:57 +0100 Subject: [PATCH 017/118] small changes for NH3 measurements --- src/ixdat/techniques/deconvolution.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ixdat/techniques/deconvolution.py b/src/ixdat/techniques/deconvolution.py index 88ac1c3d..3c8d57aa 100644 --- a/src/ixdat/techniques/deconvolution.py +++ b/src/ixdat/techniques/deconvolution.py @@ -37,7 +37,9 @@ def get_partial_current( t_sig, v_sig = self.get_calib_signal(signal_name, tspan=tspan, t_bg=t_bg) - kernel = kernel_obj.calculate_kernel(dt=t_sig[1] - t_sig[0]) + kernel = kernel_obj.calculate_kernel( + dt=t_sig[1] - t_sig[0], duration=t_sig[-1] - t_sig[0] + ) kernel = np.hstack((kernel, np.zeros(len(v_sig) - len(kernel)))) H = fft(kernel) # TODO: store this as well. @@ -66,7 +68,7 @@ def extract_kernel(self, signal_name, cutoff_pot=0, tspan=None, t_bg=None): if signal_name == "M32": t0 = x_curr[np.argmax(y_pot > cutoff_pot)] # time of impulse - elif signal_name == "M2": + elif signal_name == "M2" or signal_name == "M17": t0 = x_curr[np.argmax(y_pot < cutoff_pot)] else: print("mass not found") @@ -216,6 +218,8 @@ def calculate_kernel(self, dt=0.1, duration=100, norm=True, matrix=False): kernel = np.zeros(len(t_kernel)) for i in range(len(t_kernel)): kernel[i] = invertlaplace(fs, tdiff[i], method="talbot") + print(tdiff[i]) + print(kernel[i]) elif self.type is "measured": kernel = self.MS_data[1] From 663d709de72d2e83785cb636c8c7217fbb83dba4 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Tue, 16 Feb 2021 01:13:53 +0000 Subject: [PATCH 018/118] write placeholder zilien Reader and ecms Plotter --- src/ixdat/measurements.py | 2 +- src/ixdat/plotters/ecms_plotter.py | 105 +++++++++++++++++++++++++++ src/ixdat/readers/biologic.py | 41 +++++++++++ src/ixdat/readers/ec_ms_pkl.py | 112 +++++++++++++++++++---------- src/ixdat/readers/zilien.py | 39 ++++++++-- src/ixdat/techniques/ec.py | 64 +++++++++-------- src/ixdat/techniques/ec_ms.py | 15 ++++ 7 files changed, 307 insertions(+), 71 deletions(-) create mode 100644 src/ixdat/plotters/ecms_plotter.py diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index 0e496a38..96949a34 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -653,7 +653,7 @@ def fill_object_list(object_list, obj_ids, cls=None): def time_shifted(series, tstamp=None): """Return a series with the time shifted to be relative to tstamp""" - if tstamp is None: + if tstamp is None or not series: return series if tstamp == series.tstamp: return series diff --git a/src/ixdat/plotters/ecms_plotter.py b/src/ixdat/plotters/ecms_plotter.py new file mode 100644 index 00000000..6e261cdf --- /dev/null +++ b/src/ixdat/plotters/ecms_plotter.py @@ -0,0 +1,105 @@ +from matplotlib import pyplot as plt +from matplotlib import gridspec +from .ec_plotter import ECPlotter + + +class ECMSPlotter: + """A matplotlib plotter specialized in electrochemistry measurements.""" + + def __init__(self, measurement=None): + """Initiate the ECMSPlotter with its default Meausurement to plot""" + self.measurement = measurement + + def plot_measurement( + self, + *, + measurement=None, + mass_list=None, + tspan=None, + V_str=None, + J_str=None, + axes=None, + V_color="k", + J_color="r", + logplot=True, + **kwargs, + ): + measurement = measurement or self.measurement + + gs = gridspec.GridSpec(5, 1) + # gs.update(hspace=0.025) + if not axes: + axes = [plt.subplot(gs[0:3, 0])] + axes += [plt.subplot(gs[3:5, 0])] + axes += [axes[1].twinx()] + mass_list = mass_list or measurement.mass_list + if mass_list: + for mass in mass_list: + t, v = measurement.grab(mass, tspan=tspan) + v[v < MIN_SIGNAL] = MIN_SIGNAL + axes[0].plot(t, v, color=STANDARD_COLORS.get(mass, "k"), label=mass) + if measurement.potential: + ECPlotter.plot_measurement( + self, + measurement=measurement, + axes=[axes[1], axes[2]], + V_str=V_str, + J_str=J_str, + V_color=V_color, + J_color=J_color, + **kwargs, + ) + axes[0].xaxis.set_label_position("top") + axes[0].tick_params( + axis="x", top=True, bottom=False, labeltop=True, labelbottom=False + ) + axes[0].set_xlabel("time / [s]") + axes[1].set_xlabel("time / [s]") + axes[0].set_ylabel("signal / [A]") + axes[1].set_xlim(axes[0].get_xlim()) + if logplot: + axes[0].set_yscale("log") + + def plot_vs_potential(self): + pass + + +STANDARD_COLORS = { + "M2": "b", + "M4": "m", + "M18": "y", + "M28": "0.5", + "M32": "k", + "M40": "c", + "M44": "brown", + "M15": "r", + "M26": "g", + "M27": "limegreen", + "M30": "darkorange", + "M31": "yellowgreen", + "M43": "tan", + "M45": "darkgreen", + "M34": "r", + "M36": "g", + "M46": "purple", + "M48": "darkslategray", + "M20": "slateblue", + "M16": "steelblue", + "M19": "teal", + "M17": "chocolate", + "M41": "#FF2E2E", + "M42": "olive", + "M29": "#001146", + "M70": "purple", + "M3": "orange", + "M73": "crimson", + "M74": "r", + "M60": "g", + "M58": "darkcyan", + "M88": "darkred", + "M89": "darkmagenta", + "M130": "purple", + "M132": "purple", +} + +MIN_SIGNAL = 1e-14 diff --git a/src/ixdat/readers/biologic.py b/src/ixdat/readers/biologic.py index afa24a02..abd942be 100644 --- a/src/ixdat/readers/biologic.py +++ b/src/ixdat/readers/biologic.py @@ -250,6 +250,47 @@ def timestamp_string_to_tstamp(timestamp_string, form=None): return tstamp +BIOLOGIC_COLUMN_NAMES = ( + "mode", + "ox/red", + "error", + "control changes", + "time/s", + "control/V", + "Ewe/V", + "/mA", + "(Q-Qo)/C", + "P/W", + "loop number", + "I/mA", + "control/mA", + "Ns changes", + "counter inc.", + "cycle number", + "Ns", + "(Q-Qo)/mA.h", + "dQ/C", + "Q charge/discharge/mA.h", + "half cycle", + "Capacitance charge/µF", + "Capacitance discharge/µF", + "dq/mA.h", + "Q discharge/mA.h", + "Q charge/mA.h", + "Capacity/mA.h", + "file number", + "file_number", + "Ece/V", + "Ewe-Ece/V", + "/V", + "/V", + "Energy charge/W.h", + "Energy discharge/W.h", + "Efficiency/%", + "Rcmp/Ohm", +) + + if __name__ == "__main__": """Module demo here. diff --git a/src/ixdat/readers/ec_ms_pkl.py b/src/ixdat/readers/ec_ms_pkl.py index 64f9e341..48464bc7 100644 --- a/src/ixdat/readers/ec_ms_pkl.py +++ b/src/ixdat/readers/ec_ms_pkl.py @@ -1,7 +1,9 @@ +from pathlib import Path from . import TECHNIQUE_CLASSES import pickle from ..data_series import TimeSeries, ValueSeries from ..measurements import Measurement +from .biologic import BIOLOGIC_COLUMN_NAMES, get_column_unit ECMSMeasruement = TECHNIQUE_CLASSES["EC-MS"] @@ -13,9 +15,8 @@ class EC_MS_CONVERTER: def __init__(self): print("Reader of old ECMS .pkl files") - def read(self, file_path, cls=None): + def read(self, file_path, cls=None, **kwargs): """Return an ECMSMeasurement with the data recorded in path_to_file - # TODO: Always returns ECMS class even when read with DecoMeasurement.read() This loops through the keys of the EC-MS dict and searches for MS and EC data. Names the dataseries according to their names in the original dict. Omitts any other data as well as metadata. @@ -25,45 +26,82 @@ def read(self, file_path, cls=None): ".pkl" extension. """ with open(file_path, "rb") as f: - data = pickle.load(f) + ec_ms_dict = pickle.load(f) - cols_str = data["data_cols"] - cols_list = [] - - for col in cols_str: - if col.endswith("-x"): - cols_list.append(TimeSeries(col, "s", data[col], data["tstamp"])) + return measurement_from_ec_ms_dataset( + ec_ms_dict, + name=Path(file_path).name, + cls=cls, + reader=self, + technique="EC-MS", + **kwargs, + ) - if col == "time/s": - cols_list.append(TimeSeries(col, "s", data[col], data["tstamp"])) - measurement = Measurement( - "tseries_ms", technique="EC_MS", series_list=cols_list +def measurement_from_ec_ms_dataset( + ec_ms_dict, + name=None, + cls=ECMSMeasruement, + reader=None, + **kwargs, +): + """Return an ixdat Measurement with the data from an EC_MS data dictionary. + + Args: + ec_ms_dict (dict): The EC_MS data dictionary + name (str): Name of the measurement + cls (Measurement class): The class to return a measurement of + reader (Reader object): typically what calls this funciton with its read() method + """ + + cols_str = ec_ms_dict["data_cols"] + cols_list = [] + + name = name or ec_ms_dict.get("title", None) + + for col in cols_str: + if col.endswith("-x"): + cols_list.append( + TimeSeries(col, "s", ec_ms_dict[col], ec_ms_dict["tstamp"]) + ) + + if "time/s" in ec_ms_dict: + cols_list.append( + TimeSeries("time/s", "s", ec_ms_dict["time/s"], ec_ms_dict["tstamp"]) ) - for col in cols_str: - if col.startswith("M") and col.endswith("-y"): - cols_list.append( - ValueSeries( - col[:-2], "A", data[col], tseries=measurement[col[:-1] + "x"] - ) + measurement = Measurement("tseries_ms", technique="EC_MS", series_list=cols_list) + + for col in cols_str: + if col.endswith("-y"): + unit_name = "A" if col.startswith("M") else "" + cols_list.append( + ValueSeries( + col[:-2], + unit_name=unit_name, + data=ec_ms_dict[col], + tseries=measurement[col[:-1] + "x"], ) - - # TODO: Import all EC data. - if col == "Ewe/V" or col == "I/mA": - cols_list.append( - ValueSeries(col, "A", data[col], tseries=measurement["time/s"]) + ) + # TODO: Import all EC data. + if col in BIOLOGIC_COLUMN_NAMES and col not in measurement.series_names: + cols_list.append( + ValueSeries( + name=col, + data=ec_ms_dict[col], + unit_name=get_column_unit(col), + tseries=measurement["time/s"], ) - - obj_as_dict = dict( - name=file_path, - technique="EC_MS", - series_list=cols_list, - reader=self, - tstamp=data["tstamp"], - ) - # print(f"{__name__}. cls={cls}") # debugging - - measurement = cls.from_dict(obj_as_dict) - - return measurement + ) + + obj_as_dict = dict( + name=name, + technique="EC_MS", + series_list=cols_list, + reader=reader, + tstamp=ec_ms_dict["tstamp"], + ) + obj_as_dict.update(kwargs) + + measurement = cls.from_dict(obj_as_dict) + return measurement diff --git a/src/ixdat/readers/zilien.py b/src/ixdat/readers/zilien.py index df5fc02d..5bf8feab 100644 --- a/src/ixdat/readers/zilien.py +++ b/src/ixdat/readers/zilien.py @@ -1,9 +1,40 @@ from . import TECHNIQUE_CLASSES +from .ec_ms_pkl import measurement_from_ec_ms_dataset -ECMSMeasruement = TECHNIQUE_CLASSES["EC-MS"] +ECMSMeasurement = TECHNIQUE_CLASSES["EC-MS"] class ZilienTSVReader: - def read(self): - # return ECMSMeasurement(**obj_as_dict) - pass + def read(self, path_to_file, cls=None, name=None, **kwargs): + cls = cls or ECMSMeasurement + from EC_MS import Zilien_Dataset + + ec_ms_dataset = Zilien_Dataset(path_to_file) + return measurement_from_ec_ms_dataset( + ec_ms_dataset.data, + cls=cls, + name=name, + reader=self, + technique="EC-MS", + **kwargs + ) + + +if __name__ == "__main__": + + from pathlib import Path + from ixdat.measurements import Measurement + + path_to_test_file = Path.home() / ( + "Dropbox/ixdat_resources/test_data/" + # "2021-02-01 14_50_40 Chip 2 test water/" + # "2021-02-01 14_50_40 Chip 2 test water.tsv" + "2021-02-01 17_44_12 NMC vs Li 0.1C/2021-02-01 17_44_12 NMC vs Li 0.tsv" + ) + + ecms_measurement = Measurement.read( + reader="zilien", + path_to_file=path_to_test_file, + ) + + ecms_measurement.plot_measurement() diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index 564cbeb3..df22a47a 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -198,35 +198,39 @@ def __init__( self._raw_current = None self._selector = None self._file_number = None - 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, + 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 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 + self._populate_constants() # So that everything has a cycle number def _populate_constants(self): """Replace any ConstantValues with ValueSeries on potential's tseries @@ -325,6 +329,7 @@ def _find_or_build_raw_potential(self): self.E_str ] = self._raw_potential # TODO: Better cache'ing. This saves. else: + return # TODO: Need better handling here. raise SeriesNotFoundError( f"{self} does not have a series corresponding to raw potential." f" Looked for series with names in {self.raw_potential_names}" @@ -370,6 +375,7 @@ def _find_or_build_raw_current(self): self.I_str ] = self._raw_current # TODO: better cache'ing. This is saved else: + return # TODO Need better handling so EC-MS with only MS data works. raise SeriesNotFoundError( f"{self} does not have a series corresponding to raw current." f" Looked for series with names in {self.raw_current_names}" @@ -558,9 +564,9 @@ def cycle_number(self): tseries=cycle.tseries, ) else: + return raise SeriesNotFoundError( - f"{self} does not have a series corresponding to raw current." - f" Looked for series with names in {self.raw_current_names}" + f"{self} does not have a series corresponding to cycle number." ) # TODO: better cache'ing. This one is not cache'd at all diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index e3a5e9f3..fe1d3cba 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -1,4 +1,5 @@ """Module for representation and analysis of EC-MS measurements""" +import re from .ec import ECMeasurement from .ms import MSMeasurement @@ -9,3 +10,17 @@ class ECMSMeasurement(ECMeasurement, MSMeasurement): def __init__(self, name, **kwargs): super().__init__(name, **kwargs) + + @property + def plotter(self): + """The default plotter for ECMeasurement is ECPlotter""" + if not self._plotter: + from ..plotters.ecms_plotter import ECMSPlotter + + self._plotter = ECMSPlotter(measurement=self) + + return self._plotter + + @property + def mass_list(self): + return [col for col in self.series_names if re.search("^M[0-9]+$", col)] From e5a7d8621af9d8cd9e9b6597b1e85ea49d442760 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Tue, 16 Feb 2021 10:38:18 +0000 Subject: [PATCH 019/118] Add MSPlotter, improve EC-MS related docstrings --- src/ixdat/plotters/ecms_plotter.py | 108 ++++++++++++++--------------- src/ixdat/plotters/ms_plotter.py | 87 +++++++++++++++++++++++ src/ixdat/readers/biologic.py | 2 + src/ixdat/readers/ec_ms_pkl.py | 9 +-- src/ixdat/readers/zilien.py | 16 ++++- src/ixdat/techniques/ec_ms.py | 6 -- src/ixdat/techniques/ms.py | 6 ++ 7 files changed, 164 insertions(+), 70 deletions(-) create mode 100644 src/ixdat/plotters/ms_plotter.py diff --git a/src/ixdat/plotters/ecms_plotter.py b/src/ixdat/plotters/ecms_plotter.py index 6e261cdf..eabfbd8f 100644 --- a/src/ixdat/plotters/ecms_plotter.py +++ b/src/ixdat/plotters/ecms_plotter.py @@ -1,10 +1,11 @@ from matplotlib import pyplot as plt from matplotlib import gridspec from .ec_plotter import ECPlotter +from .ms_plotter import MSPlotter class ECMSPlotter: - """A matplotlib plotter specialized in electrochemistry measurements.""" + """A matplotlib plotter for EC-MS measurements.""" def __init__(self, measurement=None): """Initiate the ECMSPlotter with its default Meausurement to plot""" @@ -14,92 +15,85 @@ def plot_measurement( self, *, measurement=None, + axes=None, mass_list=None, tspan=None, V_str=None, J_str=None, - axes=None, V_color="k", J_color="r", logplot=True, + legend=True, **kwargs, ): + """Make an EC-MS plot vs time and return the axis handles. + + Allocates tasks to ECPlotter.plot_measurement() and MSPlotter.plot_measurement() + + TODO: add all functionality in the legendary plot_experiment() in EC_MS.Plotting + - variable subplot sizing (emphasizing EC or MS) + - plotting of calibrated data (mol_list instead of mass_list) + - units! + - optionally two y-axes in the upper panel + Args: + measurement (ECMSMeasurement): defaults to the measurement to which the + plotter is bound (self.measurement) + axes (list of three matplotlib axes): axes[0] plots the MID data, + axes[1] the variable given by V_str (potential), and axes[2] the + variable given by J_str (current). By default three axes are made with + axes[0] a top panel with 3/5 the area, and axes[1] and axes[2] are + the left and right y-axes of the lower panel with 2/5 the area. + mass_list (list of str): The names of the m/z values, eg. ["M2", ...] to + plot. Defaults to all of them (measurement.mass_list) + tspan (iter of float): The time interval to plot, wrt measurement.tstamp + V_str (str): The name of the value to plot on the lower left y-axis. + Defaults to the name of the series `measurement.potential` + J_str (str): The name of the value to plot on the lower right y-axis. + Defaults to the name of the series `measurement.current` + V_color (str): The color to plot the variable given by 'V_str' + J_color (str): The color to plot the variable given by 'J_str' + logplot (bool): Whether to plot the MS data on a log scale (default True) + legend (bool): Whether to use a legend for the MS data (default True) + kwargs (dict): Additional kwargs go to all calls of matplotlib's plot() + """ measurement = measurement or self.measurement - gs = gridspec.GridSpec(5, 1) - # gs.update(hspace=0.025) if not axes: + gs = gridspec.GridSpec(5, 1) + # gs.update(hspace=0.025) axes = [plt.subplot(gs[0:3, 0])] axes += [plt.subplot(gs[3:5, 0])] axes += [axes[1].twinx()] - mass_list = mass_list or measurement.mass_list - if mass_list: - for mass in mass_list: - t, v = measurement.grab(mass, tspan=tspan) - v[v < MIN_SIGNAL] = MIN_SIGNAL - axes[0].plot(t, v, color=STANDARD_COLORS.get(mass, "k"), label=mass) - if measurement.potential: + if hasattr(measurement, "potential") and measurement.potential: + # then we have EC data! ECPlotter.plot_measurement( self, measurement=measurement, axes=[axes[1], axes[2]], + tspan=tspan, V_str=V_str, J_str=J_str, V_color=V_color, J_color=J_color, **kwargs, ) + if mass_list or hasattr(measurement, "mass_list"): + # then we have MS data! + MSPlotter.plot_measurement( + self, + ax=axes[0], + tspan=tspan, + mass_list=mass_list, + logplot=logplot, + legend=legend, + ) axes[0].xaxis.set_label_position("top") axes[0].tick_params( axis="x", top=True, bottom=False, labeltop=True, labelbottom=False ) - axes[0].set_xlabel("time / [s]") - axes[1].set_xlabel("time / [s]") - axes[0].set_ylabel("signal / [A]") axes[1].set_xlim(axes[0].get_xlim()) - if logplot: - axes[0].set_yscale("log") + return axes def plot_vs_potential(self): + """FIXME: This is needed due to assignment in ECMeasurement.__init__""" pass - - -STANDARD_COLORS = { - "M2": "b", - "M4": "m", - "M18": "y", - "M28": "0.5", - "M32": "k", - "M40": "c", - "M44": "brown", - "M15": "r", - "M26": "g", - "M27": "limegreen", - "M30": "darkorange", - "M31": "yellowgreen", - "M43": "tan", - "M45": "darkgreen", - "M34": "r", - "M36": "g", - "M46": "purple", - "M48": "darkslategray", - "M20": "slateblue", - "M16": "steelblue", - "M19": "teal", - "M17": "chocolate", - "M41": "#FF2E2E", - "M42": "olive", - "M29": "#001146", - "M70": "purple", - "M3": "orange", - "M73": "crimson", - "M74": "r", - "M60": "g", - "M58": "darkcyan", - "M88": "darkred", - "M89": "darkmagenta", - "M130": "purple", - "M132": "purple", -} - -MIN_SIGNAL = 1e-14 diff --git a/src/ixdat/plotters/ms_plotter.py b/src/ixdat/plotters/ms_plotter.py new file mode 100644 index 00000000..9eb01c16 --- /dev/null +++ b/src/ixdat/plotters/ms_plotter.py @@ -0,0 +1,87 @@ +from matplotlib import pyplot as plt + + +class MSPlotter: + """A matplotlib plotter specialized in mass spectrometry MID measurements.""" + + def __init__(self, measurement=None): + """Initiate the ECMSPlotter with its default Meausurement to plot""" + self.measurement = measurement + + def plot_measurement( + self, + measurement=None, + ax=None, + mass_list=None, + tspan=None, + logplot=True, + legend=True, + ): + """Plot m/z signal vs time (MID) data and return the axis handle. + + Args: + measurement (MSMeasurement): defaults to the one that initiated the plotter + ax (matplotlib axis): Defaults to a new axis + mass_list (list of str): The names of the m/z values, eg. ["M2", ...] to + plot. Defaults to all of them (measurement.mass_list) + tspan (iter of float): The timespan, wrt measurement.tstamp, on which to plot + logplot (bool): Whether to plot the MS data on a log scale (default True) + legend (bool): Whether to use a legend for the MS data (default True) + """ + if not ax: + fig, ax = plt.subplots() + ax.set_ylabel("signal / [A]") + ax.set_xlabel("time / [s]") + measurement = measurement or self.measurement + mass_list = mass_list or measurement.mass_list + for mass in mass_list: + t, v = measurement.grab(mass, tspan=tspan) + v[v < MIN_SIGNAL] = MIN_SIGNAL + ax.plot(t, v, color=STANDARD_COLORS.get(mass, "k"), label=mass) + if logplot: + ax.set_yscale("log") + if legend: + ax.legend() + + +MIN_SIGNAL = 1e-14 # So that the bottom half of the plot isn't wasted on log(noise) + +# ----- These are the standard colors for EC-MS plots! ------- # + +STANDARD_COLORS = { + "M2": "b", + "M4": "m", + "M18": "y", + "M28": "0.5", + "M32": "k", + "M40": "c", + "M44": "brown", + "M15": "r", + "M26": "g", + "M27": "limegreen", + "M30": "darkorange", + "M31": "yellowgreen", + "M43": "tan", + "M45": "darkgreen", + "M34": "r", + "M36": "g", + "M46": "purple", + "M48": "darkslategray", + "M20": "slateblue", + "M16": "steelblue", + "M19": "teal", + "M17": "chocolate", + "M41": "#FF2E2E", + "M42": "olive", + "M29": "#001146", + "M70": "purple", + "M3": "orange", + "M73": "crimson", + "M74": "r", + "M60": "g", + "M58": "darkcyan", + "M88": "darkred", + "M89": "darkmagenta", + "M130": "purple", + "M132": "purple", +} diff --git a/src/ixdat/readers/biologic.py b/src/ixdat/readers/biologic.py index abd942be..a31aefba 100644 --- a/src/ixdat/readers/biologic.py +++ b/src/ixdat/readers/biologic.py @@ -250,6 +250,8 @@ def timestamp_string_to_tstamp(timestamp_string, form=None): return tstamp +# This tuple contains variable names encountered in .mpt files. The tuple can be used by +# other modules to tell which data is from biologic. BIOLOGIC_COLUMN_NAMES = ( "mode", "ox/red", diff --git a/src/ixdat/readers/ec_ms_pkl.py b/src/ixdat/readers/ec_ms_pkl.py index 48464bc7..ee3b07a7 100644 --- a/src/ixdat/readers/ec_ms_pkl.py +++ b/src/ixdat/readers/ec_ms_pkl.py @@ -17,9 +17,7 @@ def __init__(self): def read(self, file_path, cls=None, **kwargs): """Return an ECMSMeasurement with the data recorded in path_to_file - This loops through the keys of the EC-MS dict and searches for MS and - EC data. Names the dataseries according to their names in the original - dict. Omitts any other data as well as metadata. + Most of the work is done by module-level function measurement_from_ec_ms_dataset Args: path_to_file (Path): The full abs or rel path including the @@ -47,6 +45,10 @@ def measurement_from_ec_ms_dataset( ): """Return an ixdat Measurement with the data from an EC_MS data dictionary. + This loops through the keys of the EC-MS dict and searches for MS and + EC data. Names the dataseries according to their names in the original + dict. Omitts any other data as well as metadata. + Args: ec_ms_dict (dict): The EC_MS data dictionary name (str): Name of the measurement @@ -83,7 +85,6 @@ def measurement_from_ec_ms_dataset( tseries=measurement[col[:-1] + "x"], ) ) - # TODO: Import all EC data. if col in BIOLOGIC_COLUMN_NAMES and col not in measurement.series_names: cols_list.append( ValueSeries( diff --git a/src/ixdat/readers/zilien.py b/src/ixdat/readers/zilien.py index 5bf8feab..2828631b 100644 --- a/src/ixdat/readers/zilien.py +++ b/src/ixdat/readers/zilien.py @@ -6,6 +6,10 @@ class ZilienTSVReader: def read(self, path_to_file, cls=None, name=None, **kwargs): + """Read a zilien file + + TODO: This is a hack using EC_MS to read the .tsv. Will be replaced. + """ cls = cls or ECMSMeasurement from EC_MS import Zilien_Dataset @@ -21,15 +25,21 @@ def read(self, path_to_file, cls=None, name=None, **kwargs): if __name__ == "__main__": + """Module demo here. + + To run this module in PyCharm, open Run Configuration and set + Module name = ixdat.readers.zilien, + and *not* + Script path = ... + """ from pathlib import Path from ixdat.measurements import Measurement path_to_test_file = Path.home() / ( "Dropbox/ixdat_resources/test_data/" - # "2021-02-01 14_50_40 Chip 2 test water/" - # "2021-02-01 14_50_40 Chip 2 test water.tsv" - "2021-02-01 17_44_12 NMC vs Li 0.1C/2021-02-01 17_44_12 NMC vs Li 0.tsv" + # "zilien_with_spectra/2021-02-01 14_50_40.tsv" + "zilien_with_ec/2021-02-01 17_44_12.tsv" ) ecms_measurement = Measurement.read( diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index fe1d3cba..b63372a9 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -1,6 +1,4 @@ """Module for representation and analysis of EC-MS measurements""" -import re - from .ec import ECMeasurement from .ms import MSMeasurement @@ -20,7 +18,3 @@ def plotter(self): self._plotter = ECMSPlotter(measurement=self) return self._plotter - - @property - def mass_list(self): - return [col for col in self.series_names if re.search("^M[0-9]+$", col)] diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index 876cf8f4..8e451068 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -1,6 +1,7 @@ """Module for representation and analysis of MS measurements""" from ..measurements import Measurement +import re import numpy as np @@ -53,3 +54,8 @@ def grab_cal_signal(self, signal_name, tspan=None, t_bg=None): time, value = self.grab_signal(signal_name, tspan=tspan, t_bg=t_bg) return time, value * self.calibration[signal_name] + + @property + def mass_list(self): + """List of the masses for which ValueSeries are contained in the measurement""" + return [col for col in self.series_names if re.search("^M[0-9]+$", col)] From 81a429fa06a3e77e1ff4a481564876a3dcb6e6df Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Tue, 16 Feb 2021 13:26:41 +0000 Subject: [PATCH 020/118] start updating documentation --- docs/source/data_series.rst | 41 ++++++++++++++ docs/source/dataset.rst | 13 ----- docs/source/figures/inheritance.svg | 1 + docs/source/figures/pluggable.svg | 1 + docs/source/index.rst | 3 +- docs/source/measurement.rst | 87 +++++++++++++++++++++++++++++ 6 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 docs/source/data_series.rst delete mode 100644 docs/source/dataset.rst create mode 100644 docs/source/figures/inheritance.svg create mode 100644 docs/source/figures/pluggable.svg create mode 100644 docs/source/measurement.rst diff --git a/docs/source/data_series.rst b/docs/source/data_series.rst new file mode 100644 index 00000000..c30150b1 --- /dev/null +++ b/docs/source/data_series.rst @@ -0,0 +1,41 @@ +.. _data_series + +The data series structure +========================= + +A ***data series*** is an object of the `DataSeries` class in the `data_series` module +or an inheriting class. It is basically a wrapper around a numpy array, which is its +`data` attribute, together with a name and a unit. Most data series also contain some +additional metadata and/or references to other data series. The most important function +of these is to keep track of everything in time, as described below. + +data series and time +____________________ + +(Copied from text in design workshop 2, in December 2020): + +Time is special! +In some deeper way, time is just another dimension… +but for hyphenated laboratory measurements, as well as multi-technique experimental projects in general (so long as samples and equipment are moving slow compared to the speed of light), time is special because it is the one measurable quantity that is always shared between all detectors. + +Absolute time (epoch timestamp) exists in two places: +- Measurement.tstamp: This timestamp is a bit decorative – it tells the measurement’s plotter and data selection methods what to use as t=0 +- TSeries.tstamp: This timestamp is truth. It defines the t=0 for the primary time data of any measurement. + +Data carriers: +- Series: The TSeries is a special case of the Series. All data carried by ixdat will be as a numpy array in a Series. All Series share a primary key (id in table series in the db diagram on the left), and in addition to the data have a name (think “column header”) and a unit. Series is a table in the ixdat database structure, with helper tables for special cases. +- TSeries: The only additional row for TSeries (table tseries) is tstamp, as described above. +- Field: Some series consist of values spanning a space defined by other series. Such a series is called a Field, and defined by a list of references to the series which define their space. In the database, this is represented in a field_axis table, of which n rows (with axis_number from 0 to n-1) will be dedicated to representing each n-D Field. +Vseries: Finally, a very common series type is a scalar value over time. This is called a VSeries, and must have a corresponding TSeries. A VSeries is actually a special case of a field, spanning a 1-d space, and so doesn’t need a new table in the db. + +Immutability! All of the data carriers above will be immutable! This means that, even though truth is preserved by adding dt to a tsteries.tstamp and subtracting dt from tseries.data, we will never do this! This is a cheap calculation that ixdat can do on demand. Same with appending corresponding series from consecutive measurements. Performing these operations on every series in a measurement set is referred to as building a combined measurement, and is only done when explicitly asked for (f.ex. to export or save the combined measurement). Building makes new Series rather than muting existing ones. A possible exception to immutability may be appending data to use ixdat on an ongoing measurement. + +The measurement, sample, and logentry tables here are pretty self-explanatory. The measurement_series table ties each measurement to all of its series. A measurement usually corresponds to a file, and so the original path_to_raw_data is included as experimenter usually encode useful information in their raw data file names and folder structures. +Additional helper tables can be added for technique-specific measurement metadata and combinations of measurements. + + + +The `data_series` module +^^^^^^^^^^^^^^^^^^^^^^^^ +.. automodule:: ixdat.data_series + :members: \ No newline at end of file diff --git a/docs/source/dataset.rst b/docs/source/dataset.rst deleted file mode 100644 index cade89ef..00000000 --- a/docs/source/dataset.rst +++ /dev/null @@ -1,13 +0,0 @@ -The dataset structure -===================== - - -The data_series module -^^^^^^^^^^^^^^^^^^^^^^ -.. automodule:: ixdat.data_series - :members: - -The dataset module -^^^^^^^^^^^^^^^^^^ -.. automodule:: ixdat.dataset - :members: \ No newline at end of file diff --git a/docs/source/figures/inheritance.svg b/docs/source/figures/inheritance.svg new file mode 100644 index 00000000..04b763b8 --- /dev/null +++ b/docs/source/figures/inheritance.svg @@ -0,0 +1 @@ +Measurement base-Plugging with Backend, etc.-Relationships with DataSeries-Appending/hyphenating with the `+` operatorECMeasurementEverything in Measurementbase, AND:-Current and potential-Calibrating RE, normalizing, correcting ohmic drop-Selecting CV cycles-Integrating over potential rangeMSMeasurementEverything in Measurementbase, AND:-Quantifying MS signals-Storing MS settings-Relationship with mass spectraECMSMeasurementEverything in ECMeasurementAND MSMeasurement, AND:-Internal calibration-Mass transport modellingDataSeries-Raw data -Unit-Keeping track of timeEvery DataSeriesknows its timestamp. Sothey line up automatically. \ No newline at end of file diff --git a/docs/source/figures/pluggable.svg b/docs/source/figures/pluggable.svg new file mode 100644 index 00000000..8a56e34e --- /dev/null +++ b/docs/source/figures/pluggable.svg @@ -0,0 +1 @@ +Measurement class-Interface to the data. (e.g.ECMeasurementgives easy access to potential and current)-Methods for processing.(e.g.calibrating electrodes)Backend-Interface to the database: Controls what save() does.Could be:-folder/file or SQL-Local or remote-open or privateReader-Interface to the files made by the data acquisition software“biologic”, “gamry”, and “CH Instruments” could all be readers for ECMeasurementPlotter-Matplotlib or external softwareInteractive or static figure, or video-CustomizeableCo-plot or separate axes? What to include by default? EtcExporter-Bringing your data elsewhere.Could be open or private, local or remote, SQL or a folder/file. \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index 841f93c8..c93dd727 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,7 +12,8 @@ We are just getting started with this project, please have patience. introduction extended_concept - dataset + measurement + data_series license Indices and tables diff --git a/docs/source/measurement.rst b/docs/source/measurement.rst new file mode 100644 index 00000000..ba1904c8 --- /dev/null +++ b/docs/source/measurement.rst @@ -0,0 +1,87 @@ +The measurement structure +========================= + +The ***measurement*** (`meas`) is the central object in the pluggable structure of ixdat, and the +main interface for user interaction. A measurement is an object of the generalized class +`Measurement`, defined in the `measurements` module, or an inheriting +***TechniqueMeasurement*** class defined in a module of the `techniques` folder. + +The general pluggable structure is defined by `Measurement`, connecting every +measurement to a *reader* for importing from text, a *backend* for saving and loading in +`ixdat`, a *plotter* for visualization, and an *exporter* for saving outside of `ixdat`. +Each TechniqueMeasurement class will likely hav its own default reader, plotter, and +exporter, while an `ixdat` session will typically work with one backend handled by the +`db` model. + +.. image:: figures/pluggable.svg + :width: 400 + :alt: Design: pluggability + +Inheritance in TechniqueMeasurement classes makes it so that related techniques +can share functionality. + +.. image:: figures/inheritance.svg + :width: 400 + :alt: Design: inheritance + +Initiating a measurement +------------------------ + +A typical workflow is to start by reading a file. For convenience, most readers are +accessible directly from `Measurement`. So, for example, to read a .mpt file exported +by Biologic's EC-Lab, one can type + +:: + >>> from ixdat import Measurement + >>> ec_meas = Measurement.read("my_file.mpt", reader="biologic") + +The biologic reader (`ixdat.readers.biologic.BiologicMPTReader`) ensures that the +object returned, `ec_meas`, is of type `ECMeasurement`. +A full list of the readers thus accessible and their names can be viewed by typing: + +:: + >>> from ixdat.readers import READER_CLASSES + >>> READER_CLASSES + +Another workflow starts with loading a measurement from the active `ixdat` backend. +This can also be done straight from `Measurement`, as follows: + +:: + >>> from ixdat import Measurement + >>> ec_meas = Measurement.get(3) + +Where the row with id=3 of the measurements table represents an electrochemistry +measurement. Here the column "technique" in the measurements table specifies which +TechniqueMeasurement class is returned. Here, it for row three of the measurements +table, the entry "technique" is "EC", ensuring `ec_meas` is an object of type +`ECMeasurement`. + +What's in a measurement +----------------------- +A measurement is basically a wrapper around a collection of `data_series` (see +:ref:`data_series`). + +There are several ways of interracting with a measurement's `data_series`: + +- `meas.grab()` is the canonical way of getting numerical data out of a +measurement. Given the name of a `ValueSeries`, it returns two numpy arrays, `t` and `v` +where `t` is the time (wrt `meas.tstamp`) and `v` is the value as a function of that +time vector. `grab` takes a series name as its first argument and can also take a `tspan` +argument in which case it cuts the vectors to return data for the specific timespan of +the measurement. +- Indexing a measurement with the name of a data series returns that data series, with +any time values tstamp'd at `meas.tstamp` +- Most TechniqueMeasureemnts provide attribute-style access to essential DataSeries and +data. For example, `ECMeasurement` has properties for `potential` and `current` series, +as well as `t`, `v, and `j` for data. +- The names of the series are available in `meas.series_names`. +- The raw series are available in `meas.series_list`. + + +The `measurements` module +^^^^^^^^^^^^^^^^^^^^^^^^^ +Here is the full in-line documentation of the `measurements` module containing the +`Measurement` class. + +.. automodule:: ixdat.measurements + :members: \ No newline at end of file From 4a6cc2953ff69287a1edd4752c1b5fb59794607d Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Tue, 16 Feb 2021 18:19:14 +0000 Subject: [PATCH 021/118] fix documentation formatting --- docs/source/conf.py | 4 + docs/source/data-series.rst | 48 +++++++++++ docs/source/data_series.rst | 41 ---------- ...ended_concept.rst => extended-concept.rst} | 3 +- docs/source/index.rst | 10 +-- docs/source/introduction.rst | 2 + docs/source/measurement.rst | 82 +++++++++---------- 7 files changed, 101 insertions(+), 89 deletions(-) create mode 100644 docs/source/data-series.rst delete mode 100644 docs/source/data_series.rst rename docs/source/{extended_concept.rst => extended-concept.rst} (98%) diff --git a/docs/source/conf.py b/docs/source/conf.py index ca99ed65..30a62bc1 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -47,6 +47,10 @@ "sphinx_rtd_theme", ] +source_suffix = { + ".rst": "restructuredtext", + ".txt": "restructuredtext" +} # Add any paths that contain templates here, relative to this directory. # templates_path = ['_templates'] diff --git a/docs/source/data-series.rst b/docs/source/data-series.rst new file mode 100644 index 00000000..6481c1ac --- /dev/null +++ b/docs/source/data-series.rst @@ -0,0 +1,48 @@ +.. _`data_series`: + +The data series structure +========================= + +A **data series** is an object of the ``DataSeries`` class in the ``data_series`` module +or an inheriting class. It is basically a wrapper around a numpy array, which is its +``data`` attribute, together with a name and a unit. Most data series also contain some +additional metadata and/or references to other data series. The most important function +of these is to keep track of everything in time, as described below. + +data series and time +-------------------- + +(Copied from text in design workshop 2, in December 2020): + +Time is special! +In some deeper way, time is just another dimension… +but for hyphenated laboratory measurements, as well as multi-technique experimental projects in general (so long as samples and equipment are moving slow compared to the speed of light), time is special because it is the one measurable quantity that is always shared between all detectors. + +Absolute time (epoch timestamp) exists in two places: +- ``Measurement.tstamp``: This timestamp is a bit decorative – it tells the measurement’s plotter and data selection methods what to use as t=0 +- ``TSeries.tstamp``: This timestamp is truth. It defines the t=0 for the primary time data of any measurement. + +Data carriers: +- ``DataSeries``: The ``TimeSeries`` is a special case of the ``DataSeries``. All data + carried by ixdat will be as a numpy array in a Series. All Series share a primary key + (id in table series in the db diagram on the left), and in addition to the data have a ``name`` (think “column header”) and a ``unit``. Series is a table in the ``ixdat`` database structure, with helper tables for special cases. +- ``TimeSeries``: The only additional row for ``TimeSeries`` (table tseries) is ``tstamp``, as described above. +- ``Field``: Some series consist of values spanning a space defined by other series. Such a series is called a ``Field``, and defined by a list of references to the series which define their space. In the database, this is represented in a field_axis table, of which n rows (with axis_number from 0 to n-1) will be dedicated to representing each n-D ``Field``. +- ``ValueSeries``: Finally, a very common series type is a scalar value over time. This is called a ``ValueSeries``, and must have a corresponding ``TimeSeries``. A ``ValueSeries`` is actually a special case of a ``Field``, spanning a 1-d space, and so doesn’t need a new table in the db. + +**Immutability!** All of the data carriers above will be immutable! This means that, +even though truth is preserved by adding ``dt`` to a ``tsteries.tstamp`` and subtracting +``dt`` from ``tseries.data``, we will never do this! This is a cheap calculation that +``ixdat`` can do on demand. Same with appending corresponding +series from consecutive measurements. Performing these operations on every series in +a measurement set is referred to as building a combined measurement, and is only done +when explicitly asked for (f.ex. to export or save the combined measurement). Building +makes new Series rather than muting existing ones. A possible exception to immutability +may be appending data to use ``ixdat`` on an ongoing measurement. + + +The ``data_series`` module +-------------------------- + +.. automodule:: ixdat.data_series + :members: \ No newline at end of file diff --git a/docs/source/data_series.rst b/docs/source/data_series.rst deleted file mode 100644 index c30150b1..00000000 --- a/docs/source/data_series.rst +++ /dev/null @@ -1,41 +0,0 @@ -.. _data_series - -The data series structure -========================= - -A ***data series*** is an object of the `DataSeries` class in the `data_series` module -or an inheriting class. It is basically a wrapper around a numpy array, which is its -`data` attribute, together with a name and a unit. Most data series also contain some -additional metadata and/or references to other data series. The most important function -of these is to keep track of everything in time, as described below. - -data series and time -____________________ - -(Copied from text in design workshop 2, in December 2020): - -Time is special! -In some deeper way, time is just another dimension… -but for hyphenated laboratory measurements, as well as multi-technique experimental projects in general (so long as samples and equipment are moving slow compared to the speed of light), time is special because it is the one measurable quantity that is always shared between all detectors. - -Absolute time (epoch timestamp) exists in two places: -- Measurement.tstamp: This timestamp is a bit decorative – it tells the measurement’s plotter and data selection methods what to use as t=0 -- TSeries.tstamp: This timestamp is truth. It defines the t=0 for the primary time data of any measurement. - -Data carriers: -- Series: The TSeries is a special case of the Series. All data carried by ixdat will be as a numpy array in a Series. All Series share a primary key (id in table series in the db diagram on the left), and in addition to the data have a name (think “column header”) and a unit. Series is a table in the ixdat database structure, with helper tables for special cases. -- TSeries: The only additional row for TSeries (table tseries) is tstamp, as described above. -- Field: Some series consist of values spanning a space defined by other series. Such a series is called a Field, and defined by a list of references to the series which define their space. In the database, this is represented in a field_axis table, of which n rows (with axis_number from 0 to n-1) will be dedicated to representing each n-D Field. -Vseries: Finally, a very common series type is a scalar value over time. This is called a VSeries, and must have a corresponding TSeries. A VSeries is actually a special case of a field, spanning a 1-d space, and so doesn’t need a new table in the db. - -Immutability! All of the data carriers above will be immutable! This means that, even though truth is preserved by adding dt to a tsteries.tstamp and subtracting dt from tseries.data, we will never do this! This is a cheap calculation that ixdat can do on demand. Same with appending corresponding series from consecutive measurements. Performing these operations on every series in a measurement set is referred to as building a combined measurement, and is only done when explicitly asked for (f.ex. to export or save the combined measurement). Building makes new Series rather than muting existing ones. A possible exception to immutability may be appending data to use ixdat on an ongoing measurement. - -The measurement, sample, and logentry tables here are pretty self-explanatory. The measurement_series table ties each measurement to all of its series. A measurement usually corresponds to a file, and so the original path_to_raw_data is included as experimenter usually encode useful information in their raw data file names and folder structures. -Additional helper tables can be added for technique-specific measurement metadata and combinations of measurements. - - - -The `data_series` module -^^^^^^^^^^^^^^^^^^^^^^^^ -.. automodule:: ixdat.data_series - :members: \ No newline at end of file diff --git a/docs/source/extended_concept.rst b/docs/source/extended-concept.rst similarity index 98% rename from docs/source/extended_concept.rst rename to docs/source/extended-concept.rst index c7bab708..dd9f532b 100644 --- a/docs/source/extended_concept.rst +++ b/docs/source/extended-concept.rst @@ -1,3 +1,4 @@ +.. _concept: ================ Extended concept @@ -5,7 +6,7 @@ Extended concept *By Soren B. Scott, 20H03 (August 3, 2020)* -My idea is that ``ixdat`` will have two "faces": +My idea is that ``ixdat`` will have two "faces": 1. The first face is towards the raw data and the experimenter. Here, by "combining techniques", we mean making one dataset out of separately saved data files. Electrochemistry - Mass Spectrometry (EC-MS) is a perfect example, where, typically one has data files for the potentiostat and the mass spectrometer and the data tool has to line them up in time and make one dataset for the methods to be analyzed simultaneously (in contrast to some proprietary softwares like Spectro Inlets' Zilien which combine the datasets during acquisition, but inevitably make tradeoffs in the process). This will be the core of what ixdat does. On top of that, it will have a lot of auxiliary functionality for low-level analysis of typical combined datasets - for example automated calibration of the MS data based on electrochemistry (like using the electrode current during steady hydrogen evolution to calibrate the |H2| signal).  diff --git a/docs/source/index.rst b/docs/source/index.rst index c93dd727..61635c88 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,19 +1,19 @@ -Documentation for **ixdat** -=========================== +Documentation for ``ixdat`` +########################### The in-situ experimental data tool ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -We are just getting started with this project, please have patience. +Welcome to the ``ixdat`` documentation. We hope that you can find what you are looking for here! +This documentation, like ``ixdat`` itself, is a work in progress and we welcome any feedback. .. toctree:: :maxdepth: 2 introduction - extended_concept measurement - data_series + data-series license Indices and tables diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst index 562adefb..3fbbd85a 100644 --- a/docs/source/introduction.rst +++ b/docs/source/introduction.rst @@ -2,6 +2,8 @@ Introduction ============ +For a long motivation, see :ref:`concept`. + ``ixdat`` will provide a powerful **object-oriented** interface to experimental data, especially in-situ experimental data for which it is of interest to combine data obtained simultaneously from multiple techniques. ``ixdat`` will replace the existing electrochemistry - mass spectrometry data tool, `EC_MS `_, and will thus become a powerful stand-alone tool for analysis and visualization of data acquired by the equipment of `Spectro Inlets `_ and other EC-MS solutions. diff --git a/docs/source/measurement.rst b/docs/source/measurement.rst index ba1904c8..010cf02f 100644 --- a/docs/source/measurement.rst +++ b/docs/source/measurement.rst @@ -1,17 +1,18 @@ The measurement structure ========================= -The ***measurement*** (`meas`) is the central object in the pluggable structure of ixdat, and the +The **measurement** (``meas``) is the central object in the pluggable structure of ixdat, and the main interface for user interaction. A measurement is an object of the generalized class -`Measurement`, defined in the `measurements` module, or an inheriting -***TechniqueMeasurement*** class defined in a module of the `techniques` folder. +main interface for user interaction. A measurement is an object of the generalized class +``Measurement``, defined in the ``measurements`` module, or an inheriting +***TechniqueMeasurement*** class defined in a module of the ``techniques`` folder. -The general pluggable structure is defined by `Measurement`, connecting every +The general pluggable structure is defined by ``Measurement``, connecting every measurement to a *reader* for importing from text, a *backend* for saving and loading in -`ixdat`, a *plotter* for visualization, and an *exporter* for saving outside of `ixdat`. +``ixdat``, a *plotter* for visualization, and an *exporter* for saving outside of ``ixdat``. Each TechniqueMeasurement class will likely hav its own default reader, plotter, and -exporter, while an `ixdat` session will typically work with one backend handled by the -`db` model. +exporter, while an ``ixdat`` session will typically work with one backend handled by the +``db`` model. .. image:: figures/pluggable.svg :width: 400 @@ -28,60 +29,57 @@ Initiating a measurement ------------------------ A typical workflow is to start by reading a file. For convenience, most readers are -accessible directly from `Measurement`. So, for example, to read a .mpt file exported -by Biologic's EC-Lab, one can type +accessible directly from ``Measurement``. So, for example, to read a .mpt file exported +by Biologic's EC-Lab, one can type: -:: - >>> from ixdat import Measurement - >>> ec_meas = Measurement.read("my_file.mpt", reader="biologic") +>>> from ixdat import Measurement +>>> ec_meas = Measurement.read("my_file.mpt", reader="biologic") -The biologic reader (`ixdat.readers.biologic.BiologicMPTReader`) ensures that the -object returned, `ec_meas`, is of type `ECMeasurement`. +The biologic reader (``ixdat.readers.biologic.BiologicMPTReader``) ensures that the +object returned, ``ec_meas``, is of type ``ECMeasurement``. A full list of the readers thus accessible and their names can be viewed by typing: -:: - >>> from ixdat.readers import READER_CLASSES - >>> READER_CLASSES +>>> from ixdat.readers import READER_CLASSES +>>> READER_CLASSES -Another workflow starts with loading a measurement from the active `ixdat` backend. -This can also be done straight from `Measurement`, as follows: +Another workflow starts with loading a measurement from the active ``ixdat`` backend. +This can also be done straight from ``Measurement``, as follows: -:: - >>> from ixdat import Measurement - >>> ec_meas = Measurement.get(3) +>>> from ixdat import Measurement +>>> ec_meas = Measurement.get(3) Where the row with id=3 of the measurements table represents an electrochemistry measurement. Here the column "technique" in the measurements table specifies which TechniqueMeasurement class is returned. Here, it for row three of the measurements -table, the entry "technique" is "EC", ensuring `ec_meas` is an object of type -`ECMeasurement`. +table, the entry "technique" is "EC", ensuring ``ec_meas`` is an object of type +``ECMeasurement``. What's in a measurement ----------------------- -A measurement is basically a wrapper around a collection of `data_series` (see +A measurement is basically a wrapper around a collection of ``data_series`` (see :ref:`data_series`). -There are several ways of interracting with a measurement's `data_series`: +There are several ways of interracting with a measurement's ``data_series``: -- `meas.grab()` is the canonical way of getting numerical data out of a -measurement. Given the name of a `ValueSeries`, it returns two numpy arrays, `t` and `v` -where `t` is the time (wrt `meas.tstamp`) and `v` is the value as a function of that -time vector. `grab` takes a series name as its first argument and can also take a `tspan` -argument in which case it cuts the vectors to return data for the specific timespan of -the measurement. +- ``meas.grab()`` is the canonical way of getting numerical data out of a + measurement. Given the name of a ``ValueSeries``, it returns two numpy arrays, ``t`` and ``v`` + where ``t`` is the time (wrt ``meas.tstamp``) and ``v`` is the value as a function of that + time vector. ``grab`` takes a series name as its first argument and can also take a ``tspan`` + argument in which case it cuts the vectors to return data for the specific timespan of + the measurement. - Indexing a measurement with the name of a data series returns that data series, with -any time values tstamp'd at `meas.tstamp` + any time values tstamp'd at ``meas.tstamp`` - Most TechniqueMeasureemnts provide attribute-style access to essential DataSeries and -data. For example, `ECMeasurement` has properties for `potential` and `current` series, -as well as `t`, `v, and `j` for data. -- The names of the series are available in `meas.series_names`. -- The raw series are available in `meas.series_list`. + data. For example, ``ECMeasurement`` has properties for ``potential`` and ``current`` series, + as well as ``t``, ``v``, and ``j`` for data. +- The names of the series are available in ``meas.series_names``. +- The raw series are available in ``meas.series_list``. -The `measurements` module -^^^^^^^^^^^^^^^^^^^^^^^^^ -Here is the full in-line documentation of the `measurements` module containing the -`Measurement` class. +The ``measurements`` module +--------------------------- +Here is the full in-line documentation of the ``measurements`` module containing the +``Measurement`` class. .. automodule:: ixdat.measurements - :members: \ No newline at end of file + :members: From 6f707877ecf7e9c5956a01acf04c67e369a65e5a Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Tue, 16 Feb 2021 21:46:55 +0000 Subject: [PATCH 022/118] add read_url() method to Measurement --- docs/source/conf.py | 5 +---- docs/source/data-series.rst | 2 ++ src/ixdat/config.py | 7 +++++++ src/ixdat/measurements.py | 10 ++++++++++ src/ixdat/readers/biologic.py | 7 ++++--- src/ixdat/readers/reading_tools.py | 13 +++++++++++++ 6 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 src/ixdat/readers/reading_tools.py diff --git a/docs/source/conf.py b/docs/source/conf.py index 30a62bc1..2e1bd2b2 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -47,10 +47,7 @@ "sphinx_rtd_theme", ] -source_suffix = { - ".rst": "restructuredtext", - ".txt": "restructuredtext" -} +source_suffix = {".rst": "restructuredtext", ".txt": "restructuredtext"} # Add any paths that contain templates here, relative to this directory. # templates_path = ['_templates'] diff --git a/docs/source/data-series.rst b/docs/source/data-series.rst index 6481c1ac..91de16e3 100644 --- a/docs/source/data-series.rst +++ b/docs/source/data-series.rst @@ -19,10 +19,12 @@ In some deeper way, time is just another dimension… but for hyphenated laboratory measurements, as well as multi-technique experimental projects in general (so long as samples and equipment are moving slow compared to the speed of light), time is special because it is the one measurable quantity that is always shared between all detectors. Absolute time (epoch timestamp) exists in two places: + - ``Measurement.tstamp``: This timestamp is a bit decorative – it tells the measurement’s plotter and data selection methods what to use as t=0 - ``TSeries.tstamp``: This timestamp is truth. It defines the t=0 for the primary time data of any measurement. Data carriers: + - ``DataSeries``: The ``TimeSeries`` is a special case of the ``DataSeries``. All data carried by ixdat will be as a numpy array in a Series. All Series share a primary key (id in table series in the db diagram on the left), and in addition to the data have a ``name`` (think “column header”) and a ``unit``. Series is a table in the ``ixdat`` database structure, with helper tables for special cases. diff --git a/src/ixdat/config.py b/src/ixdat/config.py index 0ab4d5c0..dc760b41 100644 --- a/src/ixdat/config.py +++ b/src/ixdat/config.py @@ -20,5 +20,12 @@ def __init__(self): self.standard_data_directory = Path.home() / "ixdat" self.default_project_name = "test" + @property + def ixdat_temp_dir(self): + temp_dir = self.standard_data_directory / "temp" + if not temp_dir.exists(): + temp_dir.mkdir(parents=True) + return temp_dir + CFG = Config() diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index 96949a34..8b268633 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -157,6 +157,16 @@ def read(cls, path_to_file, reader, **kwargs): # print(f"{__name__}. cls={cls}") # debugging return reader.read(path_to_file, cls=cls, **kwargs) + @classmethod + def read_url(cls, url, reader, **kwargs): + """Read a url (via a temporary file) using the specified reader""" + from .readers.reading_tools import url_to_file + + path_to_temp_file = url_to_file(url) + measurement = cls.read(path_to_temp_file, reader=reader, **kwargs) + path_to_temp_file.unlink() + return measurement + @property def metadata_json_string(self): """Measurement metadata as a JSON-formatted string""" diff --git a/src/ixdat/readers/biologic.py b/src/ixdat/readers/biologic.py index a31aefba..d341d675 100644 --- a/src/ixdat/readers/biologic.py +++ b/src/ixdat/readers/biologic.py @@ -3,6 +3,7 @@ Demonstrated/tested at the bottom under `if __name__ == "__main__":` """ +from pathlib import Path import re import time import numpy as np @@ -92,6 +93,7 @@ def read(self, path_to_file, name=None, cls=None, **kwargs): path_to_file (Path): The full abs or rel path including the ".mpt" extension **kwargs (dict): Key-word arguments are passed to ECMeasurement.__init__ """ + path_to_file = Path(path_to_file) if path_to_file else self.path_to_file if self.file_has_been_read: print( f"This {self.__class__.__name__} has already read {self.path_to_file}." @@ -100,9 +102,9 @@ def read(self, path_to_file, name=None, cls=None, **kwargs): ) return self.measurement self.name = name or path_to_file.name - self.measurement_class = cls or ECMeasurement self.path_to_file = path_to_file - with open(path_to_file) as f: + self.measurement_class = cls or ECMeasurement + with open(self.path_to_file) as f: for line in f: self.process_line(line) for name in self.column_names: @@ -302,7 +304,6 @@ def timestamp_string_to_tstamp(timestamp_string, form=None): Script path = ... """ - from pathlib import Path from matplotlib import pyplot as plt from ixdat.measurements import Measurement diff --git a/src/ixdat/readers/reading_tools.py b/src/ixdat/readers/reading_tools.py new file mode 100644 index 00000000..ac286cd6 --- /dev/null +++ b/src/ixdat/readers/reading_tools.py @@ -0,0 +1,13 @@ +"""Module with possibly general-use tools for readers""" + +import urllib.request +from ..config import CFG + + +def url_to_file(url, file_name="temp", directory=None): + """Copy the contents of the url to a file and return its Path.""" + directory = directory or CFG.ixdat_temp_dir + suffix = "." + str(url).split(".")[-1] + path_to_file = (directory / file_name).with_suffix(suffix) + urllib.request.urlretrieve(url, path_to_file) + return path_to_file From e2060e77a9b0c28c40e79d488560cab1a2e2daa7 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Tue, 16 Feb 2021 23:17:05 +0000 Subject: [PATCH 023/118] updates for pypi: *$ pip install ixdat* --- setup.py | 5 ++++- src/ixdat/__init__.py | 2 +- src/ixdat/readers/biologic.py | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index d80d4738..13b234d7 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,8 @@ def read(*parts): Assume UTF-8 encoding. """ - with codecs.open(os.path.join(HERE, *parts), "rb", "utf-8") as f: + path_to_file = os.path.join(HERE, *parts) + with open(path_to_file, "r") as f: return f.read() @@ -54,6 +55,7 @@ def find_meta(meta): r"^__{meta}__ = ['\"]([^'\"]*)['\"]".format(meta=meta), META_FILE, re.M ) if meta_match: + print(f"found {meta}: '{meta_match.group(1)}'") # debugging return meta_match.group(1) raise RuntimeError("Unable to find __{meta}__ string.".format(meta=meta)) @@ -75,5 +77,6 @@ def find_meta(meta): "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ], + install_requires=read("requirements.txt").split("\n"), python_requires=">=3.6", ) diff --git a/src/ixdat/__init__.py b/src/ixdat/__init__.py index afbb2968..636926ad 100644 --- a/src/ixdat/__init__.py +++ b/src/ixdat/__init__.py @@ -1,6 +1,6 @@ """initialize ixdat, giving top-level access to a few of the important structures """ -__version__ = "0.0.2dev" +__version__ = "0.0.4dev" __title__ = "ixdat" __description__ = "The in-situ experimental data tool" __url__ = "https://github.com/ixdat/ixdat" diff --git a/src/ixdat/readers/biologic.py b/src/ixdat/readers/biologic.py index d341d675..e345f620 100644 --- a/src/ixdat/readers/biologic.py +++ b/src/ixdat/readers/biologic.py @@ -104,7 +104,7 @@ def read(self, path_to_file, name=None, cls=None, **kwargs): self.name = name or path_to_file.name self.path_to_file = path_to_file self.measurement_class = cls or ECMeasurement - with open(self.path_to_file) as f: + with open(self.path_to_file, "r") as f: for line in f: self.process_line(line) for name in self.column_names: From 8007662f6ac60515e8dc294f6bafaa2231e1cd3a Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Thu, 18 Feb 2021 00:33:03 +0000 Subject: [PATCH 024/118] add ECMeasurements.select_sweep and constants.py --- src/ixdat/constants.py | 16 ++++++++ src/ixdat/techniques/analysis_tools.py | 53 ++++++++++++++++++++++++++ src/ixdat/techniques/ec.py | 15 ++++++++ 3 files changed, 84 insertions(+) create mode 100644 src/ixdat/constants.py create mode 100644 src/ixdat/techniques/analysis_tools.py diff --git a/src/ixdat/constants.py b/src/ixdat/constants.py new file mode 100644 index 00000000..b38851d0 --- /dev/null +++ b/src/ixdat/constants.py @@ -0,0 +1,16 @@ +import numpy as np + +c = 2.997925e8 # speed of light / (m/s) +qe = 1.60219e-19 # fundamental charge / (C) +h = 6.62620e-34 # planck's constant / (J*s) +hbar = h / (2 * np.pi) # reduced planck's constant / (J*s) +NA = 6.02217e23 # Avogadro's number /(mol) or dimensionless +me = 9.10956e-31 # mass of electron / (kg) +kB = 1.38062e-23 # Boltzman constant / (J/K) + +u0 = 4 * np.pi * 1e-7 # permeability of free space / (J*s^2/(m*C^2)) +e0 = 1 / (u0 * c ** 2) # permittivity of free space / (C^2/(J*m)) + +R = NA * kB # gas constant / (J/(mol*K)) #NA in /mol +Faraday = NA * qe # Faraday's constant, C/mol +amu = 1e-3 / NA # atomic mass unit / (kg) # amu=1g/NA #NA dimensionless diff --git a/src/ixdat/techniques/analysis_tools.py b/src/ixdat/techniques/analysis_tools.py new file mode 100644 index 00000000..6b650aed --- /dev/null +++ b/src/ixdat/techniques/analysis_tools.py @@ -0,0 +1,53 @@ +import numpy as np + + +def tspan_passing_through(t, v, vspan, direction=None, t_i=None, edge=None): + + t_i = t_i if t_i is not None else t[0] - 1 + edge = edge if edge is not None else np.abs(vspan[-1] - vspan[0]) / 100 + + # define some things to generalize between anodic and cathodic + if direction is None: + direction = vspan[0] < vspan[-1] + + edge = edge if direction else -edge + + def before(a, b): + if direction: + # before means more cathodic if we want the anodic sweep + return a < b + else: + # and more anodic if we want the cathodic sweep + return a > b + + if direction: + # we start with the lower limit of V_span if we want the anodic sweep + vspan = np.sort(np.array(vspan)) + else: + # and with the upper limit of V_span if we want the cathodic sweep + vspan = -np.sort(-np.array(vspan)) + + t_before = t[ + np.argmax( + np.logical_and( + t > t_i, before(v, vspan[0] - edge) + ) # True if after t_i and comfortably out on start side + ) # first index for which V is comfortably out on start side + ] # corresponding time + t_just_before = t[ + np.argmax( + np.logical_and( + t > t_before, np.logical_not(before(v, vspan[0])) + ) # True if after t_i and in on start side + ) + - 1 # last index for which V is out on start side + ] # corresponding time + i_start = np.argmax(np.logical_and(t > t_just_before, before(vspan[0], v))) + # ^ first index of full sweep through range + t_start = t[i_start] + # ^ corresponding time + i_finish = np.argmax(np.logical_and(t > t_start, before(vspan[1], v))) - 1 + # ^ last index of full sweep through range + t_finish = t[i_finish] + # ^ corresponding time + return [t_start, t_finish] diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index df22a47a..753275aa 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -6,6 +6,7 @@ from ..data_series import ValueSeries, ConstantValue from ..exceptions import SeriesNotFoundError from ..exporters.ec_exporter import ECExporter +from .analysis_tools import tspan_passing_through class ECMeasurement(Measurement): @@ -280,6 +281,10 @@ class docstring.) Only if `item` matches neither these strings nor the names of 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 raise SeriesNotFoundError(f"{self} doesn't have item '{item}'") @property @@ -604,3 +609,13 @@ def as_cv(self): del self_as_dict["s_ids"] # Note, this works perfectly! All needed information is in self_as_dict :) return CyclicVoltammagram.from_dict(self_as_dict) + + def select_sweep(self, vspan, redox=None, t_i=None): + tspan = tspan_passing_through( + t=self.t, + v=self.v, + vspan=vspan, + direction=redox, + t_i=t_i, + ) + return self.cut(tspan=tspan) From 7f1a7186aa3829608f2bf747b4fdbcf3e7bdca7f Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 18 Feb 2021 13:05:38 +0000 Subject: [PATCH 025/118] integrate() and grab_of_t() --- src/ixdat/measurements.py | 41 +++++++++++++++++++++++++++++++++++++- src/ixdat/techniques/cv.py | 32 +++++++++++++++++++++++++++++ src/ixdat/techniques/ec.py | 11 ---------- 3 files changed, 72 insertions(+), 12 deletions(-) diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index 8b268633..e3779881 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -290,16 +290,55 @@ def __delitem__(self, series_name): self._series_list = new_series_list def grab(self, item, tspan=None): - """Return the time and value vectors for a given VSeries name cut by tspan""" + """Return a value vector with the corresponding time vector + + Grab is the *canonical* way to retrieve numerical time-dependent data from a + measurement in ixdat. The first argument is always the name of the value to get + time-resolved data for (the name of a ValueSeries). The second, optional, + argument is a timespan to select the data for. + Two vectors are returned: first time (t), then value (v). They are of the same + length so that `v` can be plotted against `t`, integrated over `t`, interpolated + via `t`, etc. `t` and `v` are returned in the units of their DataSeries. + TODO: option to specifiy desired units + + Typical usage:: + t, v = measurement.grab(potential, tspan=[0, 100] + + Args: + item (str): The name of the DataSeries to grab data for + tspan () + """ vseries = self[item] tseries = vseries.tseries v = vseries.data t = tseries.data + tseries.tstamp - self.tstamp if tspan: + if t[0] < tspan[0]: # then add a point to make sure tspan[0] is included + v_0 = np.interp(tspan[0], t, v) + t = np.append(tspan[0], t) + v = np.append(v_0, v) + if tspan[-1] < t[-1]: # then add a point to make sure tspan[-1] is included + v_end = np.interp(tspan[-1], t, v) + t = np.append(t, tspan[-1]) + v = np.append(v, v_end) mask = np.logical_and(tspan[0] < t, t < tspan[-1]) t, v = t[mask], v[mask] return t, v + def grab_for_t(self, item, t): + """Return a numpy array with the value of item interpolated to time t""" + vseries = self[item] + tseries = vseries.tseries + v_0 = vseries.data + t_0 = tseries.data + tseries.tstamp - self.tstamp + v = np.interp(t, t_0, v_0) + return v + + def integrate(self, item, tspan=None): + """Return the time integral of item in the specified timespan""" + t, v = self.grab(item, tspan) + return np.trapz(v, t) + @property def data_cols(self): """Return a set of the names of all of the measurement's VSeries and TSeries""" diff --git a/src/ixdat/techniques/cv.py b/src/ixdat/techniques/cv.py index ff98bc61..9646991b 100644 --- a/src/ixdat/techniques/cv.py +++ b/src/ixdat/techniques/cv.py @@ -2,6 +2,7 @@ from .ec import ECMeasurement from ..data_series import ValueSeries from ..exceptions import SeriesNotFoundError +from .analysis_tools import tspan_passing_through class CyclicVoltammagram(ECMeasurement): @@ -107,3 +108,34 @@ def redefine_cycle(self, start_potential=None, redox=None): self["cycle"] = new_cycle_series self.sel_str = "cycle" return self.cycle + + def select_sweep(self, vspan, t_i=None): + """Return a CyclicVoltammagram 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) + + def integrate(self, item, tspan=None, vspan=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) + return super().integrate(item, tspan) diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index 753275aa..4c181243 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -6,7 +6,6 @@ from ..data_series import ValueSeries, ConstantValue from ..exceptions import SeriesNotFoundError from ..exporters.ec_exporter import ECExporter -from .analysis_tools import tspan_passing_through class ECMeasurement(Measurement): @@ -609,13 +608,3 @@ def as_cv(self): del self_as_dict["s_ids"] # Note, this works perfectly! All needed information is in self_as_dict :) return CyclicVoltammagram.from_dict(self_as_dict) - - def select_sweep(self, vspan, redox=None, t_i=None): - tspan = tspan_passing_through( - t=self.t, - v=self.v, - vspan=vspan, - direction=redox, - t_i=t_i, - ) - return self.cut(tspan=tspan) From d46293318aa54343880d4182d1e91b365fba23bb Mon Sep 17 00:00:00 2001 From: Scott Date: Thu, 18 Feb 2021 17:21:41 +0000 Subject: [PATCH 026/118] implement CyclicVoltammagramDiff and CVDiffPlotter --- development_scripts/cv_tools_dev.py | 23 +--- src/ixdat/measurements.py | 29 +++-- src/ixdat/plotters/ec_plotter.py | 65 +++++++++- src/ixdat/techniques/analysis_tools.py | 124 +++++++++++++++++- src/ixdat/techniques/cv.py | 171 ++++++++++++++++++++++++- src/ixdat/techniques/ec.py | 4 +- 6 files changed, 377 insertions(+), 39 deletions(-) diff --git a/development_scripts/cv_tools_dev.py b/development_scripts/cv_tools_dev.py index 6eef53ea..bb13d11e 100644 --- a/development_scripts/cv_tools_dev.py +++ b/development_scripts/cv_tools_dev.py @@ -7,24 +7,15 @@ from pathlib import Path from matplotlib import pyplot as plt -from ixdat import Measurement +from ixdat.techniques import CyclicVoltammagram plt.close("all") -my_id = 5 -loaded_meas = Measurement.get(my_id) +stripping_cycle = CyclicVoltammagram.get(1) +base_cycle = CyclicVoltammagram.get(2) -cv = loaded_meas.as_cv() -cv.plot_measurement(J_str="cycle") -cv_selection = cv[10:16] +diff = stripping_cycle.diff_with(base_cycle) -cv_selection.plot_measurement(J_str="cycle") -cv_selection.redefine_cycle(start_potential=0.4, redox=1) -cv_selection.plot_measurement(J_str="cycle") - -ax = cv_selection[1].plot(label="cycle 1") -cv_selection[2].plot(ax=ax, linestyle="--", label="cycle 2") -ax.legend() - - -cv_selection.export(path_to_file=Path.home() / "test.csv") +diff.plot_diff() +diff.plot_measurement() +diff.plot() diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index e3779881..04a6bb93 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -289,7 +289,7 @@ def __delitem__(self, series_name): new_series_list.append(s) self._series_list = new_series_list - def grab(self, item, tspan=None): + def grab(self, item, tspan=None, include_endpoints=True): """Return a value vector with the corresponding time vector Grab is the *canonical* way to retrieve numerical time-dependent data from a @@ -302,26 +302,31 @@ def grab(self, item, tspan=None): TODO: option to specifiy desired units Typical usage:: - t, v = measurement.grab(potential, tspan=[0, 100] + t, v = measurement.grab(potential, tspan=[0, 100]) Args: item (str): The name of the DataSeries to grab data for - tspan () + tspan (iter of float): Defines the timespan with its first and last values. + Optional. By default the entire time of the measurement is included. + include_endpoints (bool): Whether to add a points at t = tspan[0] and + t = tspan[-1] to the data returned. Default is True. This makes + trapezoidal integration less dependent on the time resolution. """ vseries = self[item] tseries = vseries.tseries v = vseries.data t = tseries.data + tseries.tstamp - self.tstamp if tspan: - if t[0] < tspan[0]: # then add a point to make sure tspan[0] is included - v_0 = np.interp(tspan[0], t, v) - t = np.append(tspan[0], t) - v = np.append(v_0, v) - if tspan[-1] < t[-1]: # then add a point to make sure tspan[-1] is included - v_end = np.interp(tspan[-1], t, v) - t = np.append(t, tspan[-1]) - v = np.append(v, v_end) - mask = np.logical_and(tspan[0] < t, t < tspan[-1]) + if include_endpoints: + if t[0] < tspan[0]: # then add a point to include tspan[0] + v_0 = np.interp(tspan[0], t, v) + t = np.append(tspan[0], t) + v = np.append(v_0, v) + if tspan[-1] < t[-1]: # then add a point to include tspan[-1] + v_end = np.interp(tspan[-1], t, v) + t = np.append(t, tspan[-1]) + v = np.append(v, v_end) + mask = np.logical_and(tspan[0] <= t, t <= tspan[-1]) t, v = t[mask], v[mask] return t, v diff --git a/src/ixdat/plotters/ec_plotter.py b/src/ixdat/plotters/ec_plotter.py index b43dbaa9..f9748475 100644 --- a/src/ixdat/plotters/ec_plotter.py +++ b/src/ixdat/plotters/ec_plotter.py @@ -97,7 +97,7 @@ def plot_vs_potential( V_str (str): Name of the x-axis ValueSeries. Defaults to calibrated potential J_str (str): Name of the y-axis ValueSeries. Defaults to normalized current. ax (matplotlib.pyplot.Axis): The axis to plot on, if not a new one. - kwargs (dict): Additional key-word arguments are passed to matplotlib's + kwargs: Additional key-word arguments are passed to matplotlib's plot() function, including `color`. Returns matplotlib.pyplot.axis: The axis plotted on. @@ -124,3 +124,66 @@ def plot_vs_potential( ax.set_xlabel(V_str) ax.set_ylabel(J_str) return ax + + +class CVDiffPlotter: + """A matplotlib plotter for highlighting the difference between two cv's.""" + + def __init__(self, measurement=None): + """Initiate the ECPlotter with its default CyclicVoltammagramDiff to plot""" + self.measurement = measurement + + def plot(self, measurement=None, ax=None): + measurement = measurement or self.measurement + ax = ECPlotter.plot_vs_potential( + self, measurement=measurement.cv_1, axes=ax, color="g" + ) + ax = ECPlotter.plot_vs_potential( + self, measurement=measurement.cv_2, ax=ax, color="k", linestyle="--" + ) + t1, v1 = measurement.cv_1.grab("potential") + j1 = measurement.cv_1.grab_for_t("current", t=t1) + j_diff = measurement.grab_for_t("current", t=t1) + # a mask which is true when cv_1 had bigger current than cv_2: + v_scan = measurement.scan_rate.data + mask = np.logical_xor(0 < j_diff, v_scan < 0) + + ax.fill_between( + v1, j1-j_diff, j1, where=mask, alpha=0.2, color="g" + ) + ax.fill_between( + v1, j1-j_diff, j1, where=np.logical_not(mask), + alpha=0.1, hatch="//", color="g" + ) + + return ax + + def plot_measurement(self, measurement=None, axes=None, **kwargs): + measurement = measurement or self.measurement + return ECPlotter.plot_measurement( + self, measurement=measurement, axes=axes, **kwargs + ) + + def plot_diff(self, measurement=None, ax=None): + measurement = measurement or self.measurement + t, v = measurement.grab("potential") + j_diff = measurement.grab_for_t("current", t) + v_scan = measurement.scan_rate.data + # a mask which is true when cv_1 had bigger current than cv_2: + mask = np.logical_xor(0 < j_diff, v_scan < 0) + + if not ax: + fig, ax = plt.subplots() + + ax.plot(v[mask], j_diff[mask], "k-", label="cv1 > cv2") + ax.plot( + v[np.logical_not(mask)], + j_diff[np.logical_not(mask)], + "k--", label="cv1 < cv2" + ) + return ax + + def plot_vs_potential(self): + """FIXME: This is needed to satisfy ECMeasurement.__init__""" + pass + diff --git a/src/ixdat/techniques/analysis_tools.py b/src/ixdat/techniques/analysis_tools.py index 6b650aed..b3a8315c 100644 --- a/src/ixdat/techniques/analysis_tools.py +++ b/src/ixdat/techniques/analysis_tools.py @@ -1,16 +1,29 @@ import numpy as np -def tspan_passing_through(t, v, vspan, direction=None, t_i=None, edge=None): +def tspan_passing_through(t, v, vspan, direction=None, t_i=None, v_res=None): + """Return the tspan corresponding to t when v first passes through vspan + + Args: + t (np.array): independent varible data (usually time) + v (np.array): dependent variable data + vspan (iter of float): The range of v that we are interested in. + direction (bool): Whether v should be increasing (True) or decreasing + (False) as it passes through vspan. By default, the direction is + defined by whether vspan is increasing or decreasing. + t_i (float): The lowest value of t acceptable for tspan. Optional. + v_res (float): The uncertainty or resolution of the v data. v must be + at in or out of vspan by at least v_res to be considered in or out. + """ t_i = t_i if t_i is not None else t[0] - 1 - edge = edge if edge is not None else np.abs(vspan[-1] - vspan[0]) / 100 # define some things to generalize between anodic and cathodic if direction is None: direction = vspan[0] < vspan[-1] - edge = edge if direction else -edge + v_res = v_res if v_res is not None else np.abs(vspan[-1] - vspan[0]) / 100 + v_res = np.abs(v_res) if direction else -np.abs(v_res) def before(a, b): if direction: @@ -30,7 +43,7 @@ def before(a, b): t_before = t[ np.argmax( np.logical_and( - t > t_i, before(v, vspan[0] - edge) + t > t_i, before(v, vspan[0] - v_res) ) # True if after t_i and comfortably out on start side ) # first index for which V is comfortably out on start side ] # corresponding time @@ -51,3 +64,106 @@ def before(a, b): t_finish = t[i_finish] # ^ corresponding time return [t_start, t_finish] + + +def calc_sharp_v_scan(t, v, res_points=10): + """Calculate the discontinuous rate of change of v with respect to t + + t (np.array): the data of the independent variable, typically time + v (np.array): the data of the dependent variable + res_points (int): the resolution in data points, i.e. the spacing used in + the slope equation v_scan = (v2 - v1) / (t2 - t1) + """ + # the scan rate is dV/dt. This is a numerical calculation of dV/dt: + v_behind = np.append(np.tile(v[0], res_points), v[:-res_points]) + v_ahead = np.append(v[res_points:], np.tile(v[-1], res_points)) + + t_behind = np.append(np.tile(t[0], res_points), t[:-res_points]) + t_ahead = np.append(t[res_points:], np.tile(t[-1], res_points)) + + v_scan_middle = (v_ahead - v_behind) / (t_ahead - t_behind) + # ^ this is "softened" at the anodic and cathodic turns. + + # We can "sharpen" it by selectively looking ahead and behind: + v_scan_behind = (v - v_behind) / (t - t_behind) + v_scan_ahead = (v_ahead - v) / (t_ahead - t) + + # but this gives problems right at the beginning, so set those to zeros + v_scan_behind[:res_points] = np.zeros(res_points) + v_scan_ahead[-res_points:] = np.zeros(res_points) + + # now sharpen the scan rate! + v_scan = v_scan_middle + mask_use_ahead = np.logical_and( + np.abs(v_scan_ahead) > np.abs(v_scan), + np.abs(v_scan_ahead) > np.abs(v_scan_behind), + ) + v_scan[mask_use_ahead] = v_scan_ahead[mask_use_ahead] + + mask_use_behind = np.logical_and( + np.abs(v_scan_behind) > np.abs(v_scan), + np.abs(v_scan_behind) > np.abs(v_scan_ahead), + ) + v_scan[mask_use_behind] = v_scan_behind[mask_use_behind] + + return v_scan + + +def find_signed_sections(x, x_res=0.001, res_points=10): + """Return list of tuples ((i_start, i_finish), section_type) describing the vector x + + `i_start` and `i_finish` are indexes in x defining where sections start and end. + `section_type` can be "positive" (x>0), "negative" (x<0) or "zero" (x~0). + + Args: + x (np array): The data as a vector + x_res (float): The minimum value in x to be considered different from zero, + i.e. the uncertainty or resolution of the data + res_points (int): The minimum number of consecutive data points in x that must + have the same sign (or be ~0) to constitute a section of the data. + """ + pos_mask = x < -x_res + neg_mask = x > x_res + zero_mask = abs(x) < x_res + + section_types = ["positive", "negative", "zero"] + the_masks = [pos_mask, neg_mask, zero_mask] + + for mask in the_masks: + mask[-2] = False + mask[-1] = True + N = len(x) + i_start = 0 + i_finish = 0 + n_sweep = 0 + + the_next_starts = [np.argmax(mask) for mask in the_masks] + section_id = int(np.argmin(the_next_starts)) + + sections = [] + while i_start < N - 1: + I_out = np.argmin(the_masks[section_id][i_finish:]) + the_next_start = i_finish + I_out + res_points + + try: + I_in_again = np.argmax(the_masks[section_id][the_next_start:]) + except ValueError: + the_next_starts[section_id] = N + else: + the_next_starts[section_id] = the_next_start + I_in_again + # ^ and add it. + + next_section_id = int(np.argmin(the_next_starts)) + i_finish = the_next_starts[next_section_id] + + if next_section_id != section_id: + sections.append(((i_start, i_finish), section_types[section_id])) + section_id = next_section_id + n_sweep += 1 + i_start = i_finish + else: + i_start += res_points + + return sections + + diff --git a/src/ixdat/techniques/cv.py b/src/ixdat/techniques/cv.py index 9646991b..5690a83b 100644 --- a/src/ixdat/techniques/cv.py +++ b/src/ixdat/techniques/cv.py @@ -1,8 +1,8 @@ import numpy as np from .ec import ECMeasurement -from ..data_series import ValueSeries -from ..exceptions import SeriesNotFoundError -from .analysis_tools import tspan_passing_through +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 class CyclicVoltammagram(ECMeasurement): @@ -46,7 +46,17 @@ def __getitem__(self, key): @property def cycle(self): """ValueSeries: the cycle number. The default selector. see `redefine_cycle`""" - return self.selector + try: + return self.selector + except TypeError: + # FIXME: This is what happens now when a single-cycle CyclicVoltammagram is + # saved and loaded. + return ValueSeries( + name="cycle", + unit_name="", + data=np.ones(self.t.shape), + tseries=self.potential.tseries + ) def redefine_cycle(self, start_potential=None, redox=None): """Build `cycle` which iterates when passing through start_potential @@ -86,7 +96,7 @@ def redefine_cycle(self, start_potential=None, redox=None): break else: n += ( - np.argmax(mask_behind) + 5 + np.argmax(mask_behind) + 5 ) # have to be below V for 5 datapoints # print('point number on way up: ' + str(n)) # debugging @@ -139,3 +149,154 @@ def integrate(self, item, tspan=None, vspan=None): vspan=vspan, t_i=tspan[0] if tspan else None ).integrate(item) return super().integrate(item, tspan) + + @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 + + @property + def sweep_specs(self): + """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) + """ + t = self.t + ec_sweep_types = { + "positive": "anodic", + "negative": "cathodic", + "steady": "hold", + } + indexed_sweeps = find_signed_sections(self.scan_rate.data) + 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 + + def diff_with(self, other, v_list=None, cls=None): + """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. + """ + + 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.sweep_specs if spec[1] in ["anodic", "cathodic"] + ] + others_sweep_specs = [ + spec for spec in other.sweep_specs if spec[1] in ["anodic", "cathodic"] + ] + if not len(my_sweep_specs) == len(others_sweep_specs): + raise BuildError( + "Can only make diff of CyclicVoltammagrams 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 CyclicVoltammagramDiff + diff = cls.from_dict(diff_as_dict) + diff.cv_1 = self + diff.cv_2 = other + return diff + + +class CyclicVoltammagramDiff(CyclicVoltammagram): + + 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 CyclicVoltammagramDiff is CVDiffPlotter""" + if not self._plotter: + from ..plotters.ec_plotter import CVDiffPlotter + + self._plotter = CVDiffPlotter(measurement=self) + + return self._plotter \ No newline at end of file diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index 4c181243..8ff466a3 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -437,7 +437,9 @@ def potential(self): if self.R_Ohm: fixed_V_str += " (corrected)" fixed_potential_data = ( - fixed_potential_data - self.R_Ohm * self.raw_current.data * 1e-3 + 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, From 3de13807efbf34530f98271018e1df9ac6b5b4fe Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Thu, 18 Feb 2021 20:28:21 +0000 Subject: [PATCH 027/118] minor improvements for tutorials, black formatting --- src/ixdat/backends/directory_backend.py | 21 ++++++++++++------ src/ixdat/measurements.py | 10 ++++++++- src/ixdat/plotters/ec_plotter.py | 29 ++++++++++++++----------- src/ixdat/plotters/ms_plotter.py | 2 +- src/ixdat/techniques/analysis_tools.py | 2 -- src/ixdat/techniques/cv.py | 21 +++++++++--------- src/ixdat/techniques/ec.py | 5 ++--- 7 files changed, 53 insertions(+), 37 deletions(-) diff --git a/src/ixdat/backends/directory_backend.py b/src/ixdat/backends/directory_backend.py index 9d661bc0..8d5bc040 100644 --- a/src/ixdat/backends/directory_backend.py +++ b/src/ixdat/backends/directory_backend.py @@ -59,13 +59,9 @@ def __init__( metadata_suffix (str): The suffix to use for JSON-formatted metadata files data_suffix (str): The suffix to use for numpy-formatted data files """ - self.project_directory = directory / project_name - try: - self.project_directory.mkdir(parents=True, exist_ok=True) - except Exception: - raise # TODO, figure out what gets raised, then except with line below - # raise ConfigError(f"Cannot make dir '{self.standard_data_directory}'") - + self.directory = directory + self.project_name = project_name + self._project_directory = None self.metadata_suffix = metadata_suffix self.data_suffix = data_suffix super().__init__() @@ -74,6 +70,17 @@ def __init__( def name(self): return f"DirBackend({self.project_directory})" + @property + def project_directory(self): + if not self._project_directory: + self._project_directory = self.directory / self.project_name + try: + self._project_directory.mkdir(parents=True, exist_ok=True) + except Exception: + raise # TODO, figure out what gets raised, then except with line below + # raise ConfigError(f"Cannot make dir '{self.standard_data_directory}'") + return self._project_directory + def save(self, obj): """Save the Saveable object as a file corresponding to a row in a table""" if obj.data_objects: diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index 04a6bb93..b177885e 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -385,7 +385,7 @@ def get_original_m_id_of_series(self, series): return m_id_list[0] return m_id_list - def cut(self, tspan): + def cut(self, tspan, t_zero=None): """Return a new measurement with the data in the given time interval Args: @@ -394,6 +394,10 @@ def cut(self, tspan): time of the interval. Using tspan[-1] means you can directly use a long time vector that you have at hand to describe the time interval you're looking for. + t_zero (float or str): Where to put the tstamp of the returned measurement. + Default is to keep it the same as the present tstamp. If instead it is + a float, this adds the float to the present tstamp. If t_zero is "start", + tspan[0] is added to the present tstamp. """ new_series_list = [] obj_as_dict = self.as_dict() @@ -444,6 +448,10 @@ def cut(self, tspan): new_series_list.append(new_series) obj_as_dict["series_list"] = new_series_list del obj_as_dict["s_ids"] + if t_zero: + if t_zero == "start": + t_zero = tspan[0] + obj_as_dict["tstamp"] += t_zero new_measurement = self.__class__.from_dict(obj_as_dict) return new_measurement diff --git a/src/ixdat/plotters/ec_plotter.py b/src/ixdat/plotters/ec_plotter.py index f9748475..64748f97 100644 --- a/src/ixdat/plotters/ec_plotter.py +++ b/src/ixdat/plotters/ec_plotter.py @@ -57,8 +57,8 @@ def plot_measurement( J_str = J_str or ( measurement.J_str if measurement.A_el is not None else measurement.I_str ) - t_v, v = measurement.grab(V_str, tspan=tspan) - t_j, j = measurement.grab(J_str, tspan=tspan) + t_v, v = measurement.grab(V_str, tspan=tspan, include_endpoints=False) + t_j, j = measurement.grab(J_str, tspan=tspan, include_endpoints=False) if axes: ax1, ax2 = axes else: @@ -111,8 +111,8 @@ def plot_vs_potential( J_str = J_str or ( measurement.J_str if measurement.A_el is not None else measurement.I_str ) - t_v, v = measurement.grab(V_str, tspan=tspan) - t_j, j = measurement.grab(J_str, tspan=tspan) + t_v, v = measurement.grab(V_str, tspan=tspan, include_endpoints=False) + t_j, j = measurement.grab(J_str, tspan=tspan, include_endpoints=False) j_v = np.interp(t_v, t_j, j) if not ax: @@ -148,12 +148,15 @@ def plot(self, measurement=None, ax=None): v_scan = measurement.scan_rate.data mask = np.logical_xor(0 < j_diff, v_scan < 0) + ax.fill_between(v1, j1 - j_diff, j1, where=mask, alpha=0.2, color="g") ax.fill_between( - v1, j1-j_diff, j1, where=mask, alpha=0.2, color="g" - ) - ax.fill_between( - v1, j1-j_diff, j1, where=np.logical_not(mask), - alpha=0.1, hatch="//", color="g" + v1, + j1 - j_diff, + j1, + where=np.logical_not(mask), + alpha=0.1, + hatch="//", + color="g", ) return ax @@ -164,9 +167,9 @@ def plot_measurement(self, measurement=None, axes=None, **kwargs): self, measurement=measurement, axes=axes, **kwargs ) - def plot_diff(self, measurement=None, ax=None): + def plot_diff(self, measurement=None, tspan=None, ax=None): measurement = measurement or self.measurement - t, v = measurement.grab("potential") + t, v = measurement.grab("potential", tspan=tspan, include_endpoints=False) j_diff = measurement.grab_for_t("current", t) v_scan = measurement.scan_rate.data # a mask which is true when cv_1 had bigger current than cv_2: @@ -179,11 +182,11 @@ def plot_diff(self, measurement=None, ax=None): ax.plot( v[np.logical_not(mask)], j_diff[np.logical_not(mask)], - "k--", label="cv1 < cv2" + "k--", + label="cv1 < cv2", ) return ax def plot_vs_potential(self): """FIXME: This is needed to satisfy ECMeasurement.__init__""" pass - diff --git a/src/ixdat/plotters/ms_plotter.py b/src/ixdat/plotters/ms_plotter.py index 9eb01c16..acd2d269 100644 --- a/src/ixdat/plotters/ms_plotter.py +++ b/src/ixdat/plotters/ms_plotter.py @@ -35,7 +35,7 @@ def plot_measurement( measurement = measurement or self.measurement mass_list = mass_list or measurement.mass_list for mass in mass_list: - t, v = measurement.grab(mass, tspan=tspan) + t, v = measurement.grab(mass, tspan=tspan, include_endpoints=False) v[v < MIN_SIGNAL] = MIN_SIGNAL ax.plot(t, v, color=STANDARD_COLORS.get(mass, "k"), label=mass) if logplot: diff --git a/src/ixdat/techniques/analysis_tools.py b/src/ixdat/techniques/analysis_tools.py index b3a8315c..8ea94d09 100644 --- a/src/ixdat/techniques/analysis_tools.py +++ b/src/ixdat/techniques/analysis_tools.py @@ -165,5 +165,3 @@ def find_signed_sections(x, x_res=0.001, res_points=10): i_start += res_points return sections - - diff --git a/src/ixdat/techniques/cv.py b/src/ixdat/techniques/cv.py index 5690a83b..e44d339e 100644 --- a/src/ixdat/techniques/cv.py +++ b/src/ixdat/techniques/cv.py @@ -2,7 +2,11 @@ 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 +from .analysis_tools import ( + tspan_passing_through, + calc_sharp_v_scan, + find_signed_sections, +) class CyclicVoltammagram(ECMeasurement): @@ -55,7 +59,7 @@ def cycle(self): name="cycle", unit_name="", data=np.ones(self.t.shape), - tseries=self.potential.tseries + tseries=self.potential.tseries, ) def redefine_cycle(self, start_potential=None, redox=None): @@ -96,7 +100,7 @@ def redefine_cycle(self, start_potential=None, redox=None): break else: n += ( - np.argmax(mask_behind) + 5 + np.argmax(mask_behind) + 5 ) # have to be below V for 5 datapoints # print('point number on way up: ' + str(n)) # debugging @@ -251,10 +255,7 @@ def diff_with(self, other, v_list=None, cls=None): 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 + 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) @@ -264,7 +265,7 @@ def diff_with(self, other, v_list=None, cls=None): name=name, unit_name=self[name].unit_name, data=data, - tseries=t_diff_series + tseries=t_diff_series, ) ) @@ -272,7 +273,7 @@ def diff_with(self, other, v_list=None, cls=None): del diff_as_dict["s_ids"] diff_as_dict["series_list"] = series_list - diff_as_dict["raw_current_names"] = ("raw_current", ) + diff_as_dict["raw_current_names"] = ("raw_current",) cls = cls or CyclicVoltammagramDiff diff = cls.from_dict(diff_as_dict) @@ -299,4 +300,4 @@ def plotter(self): self._plotter = CVDiffPlotter(measurement=self) - return self._plotter \ No newline at end of file + return self._plotter diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index 8ff466a3..55738fd4 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -437,9 +437,8 @@ def potential(self): 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 + 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, From 8fabdc7a87e9010522cb1e8a737b003a5c72bed6 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Fri, 19 Feb 2021 00:38:35 +0000 Subject: [PATCH 028/118] ixdat csv reader, debugged for EC --- src/ixdat/exporters/csv_exporter.py | 29 +++- src/ixdat/plotters/ec_plotter.py | 20 +-- src/ixdat/readers/__init__.py | 2 + src/ixdat/readers/ixdat_csv.py | 227 ++++++++++++++++++++++++++++ src/ixdat/techniques/ec.py | 10 +- 5 files changed, 266 insertions(+), 22 deletions(-) create mode 100644 src/ixdat/readers/ixdat_csv.py diff --git a/src/ixdat/exporters/csv_exporter.py b/src/ixdat/exporters/csv_exporter.py index 5480df4a..6d97962f 100644 --- a/src/ixdat/exporters/csv_exporter.py +++ b/src/ixdat/exporters/csv_exporter.py @@ -39,22 +39,43 @@ def export_measurement(self, measurement, path_to_file, v_list=None, tspan=None) path_to_file = Path(path_to_file) if not path_to_file.suffix: path_to_file = path_to_file.with_suffix(".csv") + + timecols = {} for v_name in v_list: t_name = measurement[v_name].tseries.name t, v = measurement.grab(v_name, tspan=tspan) - if t_name not in columns_data: + if t_name in timecols: + timecols[t_name].append(v_name) + else: columns_data[t_name] = t s_list.append(t_name) + timecols[t_name] = [v_name] columns_data[v_name] = v s_list.append(v_name) - header_line = ( + header_lines = [] + for attr in ["name", "technique", "tstamp", "backend_name", "id"]: + line = f"{attr} = {getattr(measurement, attr)}\n" + header_lines.append(line) + for t_name, v_names in timecols.items(): + line = ( + f"timecol '{t_name}' for: " + + " and ".join([f"'{v_name}'" for v_name in v_names]) + + "\n" + ) + header_lines.append(line) + + N_header_lines = len(header_lines) + 3 + header_lines.append(f"N_header_lines = {N_header_lines}\n") + header_lines.append("\n") + + col_header_line = ( "".join([s_name + self.delim for s_name in s_list])[: -len(self.delim)] + "\n" ) - # TODO: Header may have to indicate which time goes with which value. + header_lines.append(col_header_line) - lines = [header_line] + lines = header_lines max_length = max([len(data) for data in columns_data.values()]) for n in range(max_length): line = "" diff --git a/src/ixdat/plotters/ec_plotter.py b/src/ixdat/plotters/ec_plotter.py index 64748f97..a3b7e900 100644 --- a/src/ixdat/plotters/ec_plotter.py +++ b/src/ixdat/plotters/ec_plotter.py @@ -49,14 +49,8 @@ def plot_measurement( Returns list of matplotlib.pyplot.Axis: The axes plotted on. """ measurement = measurement or self.measurement - V_str = V_str or ( - measurement.V_str - if measurement.RE_vs_RHE is not None - else measurement.E_str - ) - J_str = J_str or ( - measurement.J_str if measurement.A_el is not None else measurement.I_str - ) + V_str = V_str or measurement.potential.name + J_str = J_str or measurement.current.name t_v, v = measurement.grab(V_str, tspan=tspan, include_endpoints=False) t_j, j = measurement.grab(J_str, tspan=tspan, include_endpoints=False) if axes: @@ -103,14 +97,8 @@ def plot_vs_potential( Returns matplotlib.pyplot.axis: The axis plotted on. """ measurement = measurement or self.measurement - V_str = V_str or ( - measurement.V_str - if measurement.RE_vs_RHE is not None - else measurement.E_str - ) - J_str = J_str or ( - measurement.J_str if measurement.A_el is not None else measurement.I_str - ) + V_str = V_str or measurement.potential.name + J_str = J_str or measurement.current.name t_v, v = measurement.grab(V_str, tspan=tspan, include_endpoints=False) t_j, j = measurement.grab(J_str, tspan=tspan, include_endpoints=False) diff --git a/src/ixdat/readers/__init__.py b/src/ixdat/readers/__init__.py index 653a6fbf..33262cb7 100644 --- a/src/ixdat/readers/__init__.py +++ b/src/ixdat/readers/__init__.py @@ -9,9 +9,11 @@ from .ec_ms_pkl import EC_MS_CONVERTER from .zilien import ZilienTSVReader from .biologic import BiologicMPTReader +from .ixdat_csv import IxdatCSVReader READER_CLASSES = { "EC_MS": EC_MS_CONVERTER, "zilien": ZilienTSVReader, "biologic": BiologicMPTReader, + "ixdat": IxdatCSVReader, } diff --git a/src/ixdat/readers/ixdat_csv.py b/src/ixdat/readers/ixdat_csv.py new file mode 100644 index 00000000..ef884978 --- /dev/null +++ b/src/ixdat/readers/ixdat_csv.py @@ -0,0 +1,227 @@ +"""Module defining the ixdat csv reader, so ixdat can read the files it exports.""" + +from pathlib import Path +import numpy as np +import re +from ..exceptions import ReadError +from ..data_series import ValueSeries, TimeSeries +from ..measurements import Measurement +from ..techniques import TECHNIQUE_CLASSES + +regular_expressions = { + "tstamp": r"tstamp = ([0-9\.]+)", + "technique": r"technique = (\w+)", + "N_header_lines": r"N_header_lines = ([0-9]+)", + "backend_name": r"backend_name = (\w+)", + "id": r"id = ([0-9]+)", + "timecol": r"timecol '(.+)' for: (?:'(.+)')$", + "unit": r"/ [(\w+)]", +} + + +class IxdatCSVReader: + """A class that reads the csv's made by ixdat.exporters.csv_exporter.CSVExporter + + read() is the important method - it takes the path to the mpt file as argument + and returns an ECMeasurement object (ec_measurement) representing that file. + The ECMeasurement contains a reference to the BiologicMPTReader object, as + ec_measurement.reader. This makes available all the following stuff, likely + useful for debugging. + + Attributes: + path_to_file (Path): the location and name of the file read by the reader + n_line (int): the number of the last line read by the reader + place_in_file (str): The last location in the file read by the reader. This + is used internally to tell the reader how to parse each line. Options are: + "header", "column names", and "data". + header_lines (list of str): a list of the header lines of the files. This + includes the column name line. The header can be nicely viewed with the + print_header() function. + tstamp (str): The unix time corresponding to t=0 + technique (str): The name of the technique + N_header_lines (int): The number of lines in the header of the file + column_names (list of str): The names of the data columns in the file + column_data (dict of str: np.array): The data in the file as a dict. + Note that the np arrays are the same ones as in the measurement's DataSeries, + so this does not waste memory. + file_has_been_read (bool): This is used to make sure read() is only successfully + called once by the Reader. False until read() is called, then True. + measurement (Measurement): The measurement returned by read() when the file is + read. self.measureemnt is None before read() is called. + """ + + delim = "," + + def __init__(self): + """Initialize a Reader for .mpt files. See class docstring.""" + self.name = None + self.path_to_file = None + self.n_line = 0 + self.place_in_file = "header" + self.header_lines = [] + self.tstamp = None + self.N_header_lines = None + self.timecols = {} + self.column_names = [] + self.column_data = {} + self.technique = None + self.measurement_class = Measurement + self.file_has_been_read = False + self.measurement = None + + def read(self, path_to_file, name=None, cls=None, **kwargs): + """Return an ECMeasurement with the data and metadata recorded in path_to_file + + This loops through the lines of the file, processing one at a time. For header + lines, this involves searching for metadata. For the column name line, this + involves creating empty arrays for each data series. For the data lines, this + involves appending to these arrays. After going through all the lines, it + converts the arrays to DataSeries. + For .mpt files, there is one TimeSeries, with name "time/s", and all other data + series are ValueSeries sharing this TimeSeries. + Finally, the method returns an ECMeasurement with these DataSeries. The + ECMeasurement contains a reference to the reader. + + Args: + path_to_file (Path): The full abs or rel path including the ".mpt" extension + **kwargs (dict): Key-word arguments are passed to ECMeasurement.__init__ + """ + path_to_file = Path(path_to_file) if path_to_file else self.path_to_file + if self.file_has_been_read: + print( + f"This {self.__class__.__name__} has already read {self.path_to_file}." + " Returning the measurement resulting from the original read. " + "Use a new Reader if you want to read another file." + ) + return self.measurement + self.name = name or path_to_file.name + self.path_to_file = path_to_file + with open(self.path_to_file, "r") as f: + for line in f: + self.process_line(line) + for name in self.column_names: + self.column_data[name] = np.array(self.column_data[name]) + + data_series_dict = {} + + for tcol_name in self.timecols: # then it's time! + data_series_dict[tcol_name] = TimeSeries( + name=tcol_name, + unit_name=get_column_unit(tcol_name) or "s", + data=self.column_data[tcol_name], + tstamp=self.tstamp, + ) + + for column_name, data in self.column_data.items(): + if column_name in self.timecols: + continue + try: + tcol_name = next( + tcol_name + for tcol_name in self.timecols + if column_name in self.timecols[tcol_name] + ) + except StopIteration: # debugging + raise ReadError( + f"can't find tcol for {column_name}. timecols={self.timecols}" + ) + + tseries = data_series_dict[tcol_name] + vseries = ValueSeries( + name=column_name, + data=data, + tseries=tseries, + unit_name=get_column_unit(column_name), + ) + data_series_dict[column_name] = vseries + + data_series_list = list(data_series_dict.values()) + obj_as_dict = dict( + name=self.name, + technique=self.technique, + reader=self, + series_list=data_series_list, + tstamp=self.tstamp, + ) + obj_as_dict.update(kwargs) + + if issubclass(cls, self.measurement_class): + self.measurement_class = cls + + self.measurement = self.measurement_class.from_dict(obj_as_dict) + self.file_has_been_read = True + return self.measurement + + def process_line(self, line): + """Call the correct line processing method depending on self.place_in_file""" + if self.place_in_file == "header": + self.process_header_line(line) + elif self.place_in_file == "column names": + self.process_column_line(line) + elif self.place_in_file == "data": + self.process_data_line(line) + else: # just for debugging + raise ReadError(f"place_in_file = {self.place_in_file}") + self.n_line += 1 + + def process_header_line(self, line): + """Search line for important metadata and set the relevant attribute of self""" + self.header_lines.append(line) + N_head_match = re.search(regular_expressions["N_header_lines"], line) + if N_head_match: + self.N_header_lines = int(N_head_match.group(1)) + return + timestamp_match = re.search(regular_expressions["tstamp"], line) + if timestamp_match: + self.tstamp = float(timestamp_match.group(1)) + return + technique_match = re.search(regular_expressions["technique"], line) + if technique_match: + self.technique = technique_match.group(1) + if self.technique in TECHNIQUE_CLASSES: + if issubclass( + TECHNIQUE_CLASSES[self.technique], self.measurement_class + ): + self.measurement_class = TECHNIQUE_CLASSES[self.technique] + return + timecol_match = re.search(regular_expressions["timecol"], line) + if timecol_match: + tcol = timecol_match.group(1) + self.timecols[tcol] = [] + for vcol in timecol_match.group(2).split("' and '"): + self.timecols[tcol].append(vcol) + if self.N_header_lines and self.n_line >= self.N_header_lines - 2: + self.place_in_file = "column names" + + def process_column_line(self, line): + """Split the line to get the names of the file's data columns""" + self.header_lines.append(line) + self.column_names = [name.strip() for name in line.split(self.delim)] + self.column_data.update({name: [] for name in self.column_names}) + self.place_in_file = "data" + + def process_data_line(self, line): + """Split the line and append the numbers the corresponding data column arrays""" + data_strings_from_line = line.strip().split(self.delim) + for name, value_string in zip(self.column_names, data_strings_from_line): + if value_string: + try: + value = float(value_string) + except ValueError: + raise ReadError(f"can't parse value string '{value_string}'") + self.column_data[name].append(value) + + def print_header(self): + """Print the file header including column names. read() must be called first.""" + header = "".join(self.header_lines) + print(header) + + +def get_column_unit(column_name): + """Return the unit name of an ixdat column, i.e the part of the name after the '/'""" + unit_match = re.search(regular_expressions["unit"], column_name) + if unit_match: + unit_name = unit_match.group(1) + else: + unit_name = None + return unit_name diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index 55738fd4..3a306159 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -284,6 +284,10 @@ class docstring.) Only if `item` matches neither these strings nor the names of 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 @@ -333,7 +337,6 @@ def _find_or_build_raw_potential(self): self.E_str ] = self._raw_potential # TODO: Better cache'ing. This saves. else: - return # TODO: Need better handling here. raise SeriesNotFoundError( f"{self} does not have a series corresponding to raw potential." f" Looked for series with names in {self.raw_potential_names}" @@ -379,7 +382,6 @@ def _find_or_build_raw_current(self): self.I_str ] = self._raw_current # TODO: better cache'ing. This is saved else: - return # TODO Need better handling so EC-MS with only MS data works. raise SeriesNotFoundError( f"{self} does not have a series corresponding to raw current." f" Looked for series with names in {self.raw_current_names}" @@ -424,6 +426,8 @@ def potential(self): `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 @@ -457,6 +461,8 @@ def current(self): 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 From 5c2448df9fce3d8396e1b9bc6e94b1eadf6c0f20 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Fri, 19 Feb 2021 08:09:05 +0000 Subject: [PATCH 029/118] v0.0.5, minor debugging for csv of ec data --- src/ixdat/__init__.py | 2 +- src/ixdat/exporters/ec_exporter.py | 6 ++++-- src/ixdat/readers/ixdat_csv.py | 7 ++++++- src/ixdat/units.py | 2 +- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/ixdat/__init__.py b/src/ixdat/__init__.py index 636926ad..bacc989a 100644 --- a/src/ixdat/__init__.py +++ b/src/ixdat/__init__.py @@ -1,6 +1,6 @@ """initialize ixdat, giving top-level access to a few of the important structures """ -__version__ = "0.0.4dev" +__version__ = "0.0.5" __title__ = "ixdat" __description__ = "The in-situ experimental data tool" __url__ = "https://github.com/ixdat/ixdat" diff --git a/src/ixdat/exporters/ec_exporter.py b/src/ixdat/exporters/ec_exporter.py index 0eb98eaf..a2e0bfa7 100644 --- a/src/ixdat/exporters/ec_exporter.py +++ b/src/ixdat/exporters/ec_exporter.py @@ -7,9 +7,11 @@ class ECExporter(CSVExporter): @property def default_v_list(self): """The default v_list for ECExporter is V_str, J_str, and sel_str""" - return [ - # self.measurement.t_str, + v_list = [ + self.measurement.E_str, + self.measurement.I_str, self.measurement.V_str, self.measurement.J_str, self.measurement.sel_str, ] + return v_list diff --git a/src/ixdat/readers/ixdat_csv.py b/src/ixdat/readers/ixdat_csv.py index ef884978..3be8fe63 100644 --- a/src/ixdat/readers/ixdat_csv.py +++ b/src/ixdat/readers/ixdat_csv.py @@ -15,7 +15,7 @@ "backend_name": r"backend_name = (\w+)", "id": r"id = ([0-9]+)", "timecol": r"timecol '(.+)' for: (?:'(.+)')$", - "unit": r"/ [(\w+)]", + "unit": r"/ [(.+)]", } @@ -148,6 +148,11 @@ def read(self, path_to_file, name=None, cls=None, **kwargs): if issubclass(cls, self.measurement_class): self.measurement_class = cls + if issubclass(self.measurement_class, TECHNIQUE_CLASSES["EC"]): + # this is how ECExporter exports current and potential: + obj_as_dict["raw_potential_names"] = ("raw potential / [V]",) + obj_as_dict["raw_current_names"] = ("raw current / [mA]",) + self.measurement = self.measurement_class.from_dict(obj_as_dict) self.file_has_been_read = True return self.measurement diff --git a/src/ixdat/units.py b/src/ixdat/units.py index c48c1b20..24994160 100644 --- a/src/ixdat/units.py +++ b/src/ixdat/units.py @@ -8,7 +8,7 @@ class Unit: """TODO: flesh out this class or find an appropriate 3rd-party to use instead""" def __init__(self, name): - self.name = name + self.name = name or "" self.si_unit = None self.si_conversion_factor = None From 7e2c87d474fa6fc9a702c7aac4d52509084fff39 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Fri, 19 Feb 2021 09:06:02 +0000 Subject: [PATCH 030/118] base plotter, integrate() takes axis argument --- src/ixdat/measurements.py | 14 +++++++++++++- src/ixdat/plotters/base_mpl_plotter.py | 12 ++++++++++++ src/ixdat/plotters/ec_plotter.py | 12 ++++++------ src/ixdat/plotters/ms_plotter.py | 8 +++----- src/ixdat/plotters/value_plotter.py | 10 ++++++---- src/ixdat/techniques/cv.py | 6 +++--- 6 files changed, 43 insertions(+), 19 deletions(-) create mode 100644 src/ixdat/plotters/base_mpl_plotter.py diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index b177885e..03547772 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -339,9 +339,21 @@ def grab_for_t(self, item, t): v = np.interp(t, t_0, v_0) return v - def integrate(self, item, tspan=None): + def integrate(self, item, tspan=None, ax=None): """Return the time integral of item in the specified timespan""" t, v = self.grab(item, tspan) + if ax: + if ax == "new": + ax = self.plotter.new_ax(ylabel=item) + # FIXME: xlabel=self[item].tseries.name gives a problem :( + ax.plot(t, v, color="k", label=item) + ax.fill_between( + t, v, np.zeros(t.shape), where=v > 0, color="g", alpha=0.3 + ) + ax.fill_between( + t, v, np.zeros(t.shape), where=v < 0, color="g", alpha=0.1, hatch="//" + ) + return np.trapz(v, t) @property diff --git a/src/ixdat/plotters/base_mpl_plotter.py b/src/ixdat/plotters/base_mpl_plotter.py new file mode 100644 index 00000000..1abb378a --- /dev/null +++ b/src/ixdat/plotters/base_mpl_plotter.py @@ -0,0 +1,12 @@ +from matplotlib import pyplot as plt + + +class MPLPlotter: + + def new_ax(self, xlabel=None, ylabel=None): + fig, ax = plt.subplots() + if xlabel: + ax.set_xlabel(xlabel) + if ylabel: + ax.set_ylabel(ylabel) + return ax \ No newline at end of file diff --git a/src/ixdat/plotters/ec_plotter.py b/src/ixdat/plotters/ec_plotter.py index a3b7e900..71f7da22 100644 --- a/src/ixdat/plotters/ec_plotter.py +++ b/src/ixdat/plotters/ec_plotter.py @@ -1,9 +1,9 @@ import numpy as np -from matplotlib import pyplot as plt +from .base_mpl_plotter import MPLPlotter from .plotting_tools import color_axis -class ECPlotter: +class ECPlotter(MPLPlotter): """A matplotlib plotter specialized in electrochemistry measurements.""" def __init__(self, measurement=None): @@ -56,7 +56,7 @@ def plot_measurement( if axes: ax1, ax2 = axes else: - fig, ax1 = plt.subplots() + ax1 = self.new_ax() ax2 = ax1.twinx() axes = [ax1, ax2] ax1.plot(t_v, v, "-", color=V_color, label=V_str, **kwargs) @@ -104,7 +104,7 @@ def plot_vs_potential( j_v = np.interp(t_v, t_j, j) if not ax: - fig, ax = plt.subplots() + ax = self.new_ax() if "color" not in kwargs: kwargs["color"] = "k" @@ -114,7 +114,7 @@ def plot_vs_potential( return ax -class CVDiffPlotter: +class CVDiffPlotter(MPLPlotter): """A matplotlib plotter for highlighting the difference between two cv's.""" def __init__(self, measurement=None): @@ -164,7 +164,7 @@ def plot_diff(self, measurement=None, tspan=None, ax=None): mask = np.logical_xor(0 < j_diff, v_scan < 0) if not ax: - fig, ax = plt.subplots() + ax = self.new_ax() ax.plot(v[mask], j_diff[mask], "k-", label="cv1 > cv2") ax.plot( diff --git a/src/ixdat/plotters/ms_plotter.py b/src/ixdat/plotters/ms_plotter.py index acd2d269..fe3a7653 100644 --- a/src/ixdat/plotters/ms_plotter.py +++ b/src/ixdat/plotters/ms_plotter.py @@ -1,7 +1,7 @@ -from matplotlib import pyplot as plt +from .base_mpl_plotter import MPLPlotter -class MSPlotter: +class MSPlotter(MPLPlotter): """A matplotlib plotter specialized in mass spectrometry MID measurements.""" def __init__(self, measurement=None): @@ -29,9 +29,7 @@ def plot_measurement( legend (bool): Whether to use a legend for the MS data (default True) """ if not ax: - fig, ax = plt.subplots() - ax.set_ylabel("signal / [A]") - ax.set_xlabel("time / [s]") + ax = self.new_ax(ylabel="signal / [A]", xlabel="time / [s]") measurement = measurement or self.measurement mass_list = mass_list or measurement.mass_list for mass in mass_list: diff --git a/src/ixdat/plotters/value_plotter.py b/src/ixdat/plotters/value_plotter.py index 7ac47754..c2f95c6a 100644 --- a/src/ixdat/plotters/value_plotter.py +++ b/src/ixdat/plotters/value_plotter.py @@ -1,10 +1,10 @@ """Classes for plotting measurement data""" -from matplotlib import pyplot as plt -from ixdat.exceptions import SeriesNotFoundError +from .base_mpl_plotter import MPLPlotter +from ..exceptions import SeriesNotFoundError -class ValuePlotter: +class ValuePlotter(MPLPlotter): """Default plotter. By default plots all of the VSeries vs time on a single axis""" def __init__(self, measurement=None): @@ -28,7 +28,7 @@ def plot_measurement( logscale (bool): Whether to use a log-scaled y-axis. Defaults to False. """ if not ax: - fig, ax = plt.subplots() + ax = self.new_ax() v_list = v_list or measurement.value_names for v_name in v_list: @@ -41,5 +41,7 @@ def plot_measurement( if legend: ax.legend() + if logscale: + ax.set_yscale("log") return ax diff --git a/src/ixdat/techniques/cv.py b/src/ixdat/techniques/cv.py index e44d339e..368b1765 100644 --- a/src/ixdat/techniques/cv.py +++ b/src/ixdat/techniques/cv.py @@ -141,7 +141,7 @@ def select_sweep(self, vspan, t_i=None): ) return self.cut(tspan=tspan) - def integrate(self, item, tspan=None, vspan=None): + 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 @@ -151,8 +151,8 @@ def integrate(self, item, tspan=None, vspan=None): if vspan: return self.select_sweep( vspan=vspan, t_i=tspan[0] if tspan else None - ).integrate(item) - return super().integrate(item, tspan) + ).integrate(item, ax=ax) + return super().integrate(item, tspan, ax=ax) @property def scan_rate(self, res_points=10): From 90965961fabc878eeb8747197e48480fb6ab07c5 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Fri, 19 Feb 2021 12:28:38 +0000 Subject: [PATCH 031/118] move higher saveable stuff to projects, v0.0.6 --- src/ixdat/__init__.py | 2 +- src/ixdat/measurements.py | 4 ++-- src/ixdat/projects/__init__.py | 0 src/ixdat/{ => projects}/lablogs.py | 2 +- src/ixdat/{ => projects}/samples.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 src/ixdat/projects/__init__.py rename src/ixdat/{ => projects}/lablogs.py (94%) rename src/ixdat/{ => projects}/samples.py (93%) diff --git a/src/ixdat/__init__.py b/src/ixdat/__init__.py index bacc989a..27c7e6c0 100644 --- a/src/ixdat/__init__.py +++ b/src/ixdat/__init__.py @@ -1,6 +1,6 @@ """initialize ixdat, giving top-level access to a few of the important structures """ -__version__ = "0.0.5" +__version__ = "0.0.6" __title__ = "ixdat" __description__ = "The in-situ experimental data tool" __url__ = "https://github.com/ixdat/ixdat" diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index 03547772..4f35c24a 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -9,8 +9,8 @@ import numpy as np from .db import Saveable, PlaceHolderObject from .data_series import DataSeries, TimeSeries, ValueSeries -from .samples import Sample -from .lablogs import LabLog +from ixdat.projects.samples import Sample +from ixdat.projects.lablogs import LabLog from ixdat.exporters.csv_exporter import CSVExporter from .exceptions import BuildError, SeriesNotFoundError # , TechniqueError diff --git a/src/ixdat/projects/__init__.py b/src/ixdat/projects/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/ixdat/lablogs.py b/src/ixdat/projects/lablogs.py similarity index 94% rename from src/ixdat/lablogs.py rename to src/ixdat/projects/lablogs.py index daa18eff..6617ab14 100644 --- a/src/ixdat/lablogs.py +++ b/src/ixdat/projects/lablogs.py @@ -1,4 +1,4 @@ -from .db import Saveable +from ixdat.db import Saveable class LabLog(Saveable): diff --git a/src/ixdat/samples.py b/src/ixdat/projects/samples.py similarity index 93% rename from src/ixdat/samples.py rename to src/ixdat/projects/samples.py index 73400a55..76161842 100644 --- a/src/ixdat/samples.py +++ b/src/ixdat/projects/samples.py @@ -1,6 +1,6 @@ """The module implements the sample class""" -from .db import Saveable +from ixdat.db import Saveable class Sample(Saveable): From 13707ff492a0090578d779e2f272633005bb3fcf Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Wed, 24 Feb 2021 21:41:27 +0000 Subject: [PATCH 032/118] autolab reader: tstamp stuff to reading_tools.py --- development_scripts/ec_tools_dev.py | 59 ++------------------- requirements.txt | 2 +- src/ixdat/__init__.py | 2 +- src/ixdat/readers/__init__.py | 20 +++++--- src/ixdat/readers/autolab.py | 70 +++++++++++++++++++++++++ src/ixdat/readers/biologic.py | 41 +++++---------- src/ixdat/readers/reading_tools.py | 80 ++++++++++++++++++++++++++++- 7 files changed, 182 insertions(+), 92 deletions(-) create mode 100644 src/ixdat/readers/autolab.py diff --git a/development_scripts/ec_tools_dev.py b/development_scripts/ec_tools_dev.py index e6012265..e697851d 100644 --- a/development_scripts/ec_tools_dev.py +++ b/development_scripts/ec_tools_dev.py @@ -9,59 +9,8 @@ from ixdat import Measurement -plt.close("all") +path_to_file = Path.home() / ( + "Dropbox/ixdat_resources/test_data/autolab/autolab_test_file.txt" +) -if False: - data_dir = Path.home() / ( - "Dropbox/ixdat_resources/20B12_Data_Analysis_Workshop/example data set/" - ) - - ocp_file = data_dir / "01_Trimi1_cont_02_OCV_C01.mpt" - cv_file = data_dir / "01_Trimi1_cont_03_CVA_C01.mpt" - cp_file = data_dir / "01_Trimi1_cont_04_CP_C01.mpt" - - ocp_meas = Measurement.read(ocp_file, reader="biologic", name="Pt_demo_ocp") - print("read ocp file!") - cv_meas = Measurement.read(cv_file, reader="biologic", name="Pt_demo_cv") - print("read cv file!") - cp_meas = Measurement.read(cp_file, reader="biologic", name="Pt_demo_cp") - print("read cp file!") - - ocp_id = ocp_meas.save() - cv_id = cv_meas.save() - cp_id = cp_meas.save() -else: - ocp_meas = Measurement.get(1) - cv_meas = Measurement.get(2) - cp_meas = Measurement.get(3) - -combined_meas = ocp_meas + cv_meas + cp_meas - -t_start = 13700 -tspan = [0, 5000] - -combined_meas.calibrate(RE_vs_RHE=0.715, A_el=0.196) - -combined_meas.tstamp += t_start - -combined_meas.plot(tspan=tspan) - -cut_meas = combined_meas.cut(tspan=tspan) -cut_meas.plot(J_str="selector") - -select_meas = cut_meas.select_values(selector=[7, 9]) -select_meas.correct_ohmic_drop(R_Ohm=200) -select_meas.plot() -if False: - select_meas.name = "selected_measurement" - select_meas.save() # this changes its ID! - my_id = select_meas.id -else: - my_id = 4 - -del select_meas - -loaded_meas = Measurement.get(my_id) - -loaded_meas.plot(J_str="selector") -loaded_meas.plot_vs_potential() +meas = Measurement.read(path_to_file, reader="autolab") diff --git a/requirements.txt b/requirements.txt index e7d6c5d8..223ec5f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,4 @@ matplotlib>=3.2 EC_MS>=0.7.4 # Temporary! For Zilien reading. scipy>=1.5 # for deconvolution (should be plugin?) mpmath>=1 # for deconvolution (should be plugin? - +pandas>=1 # for some readers and an exporter. diff --git a/src/ixdat/__init__.py b/src/ixdat/__init__.py index 27c7e6c0..c4796f7c 100644 --- a/src/ixdat/__init__.py +++ b/src/ixdat/__init__.py @@ -1,6 +1,6 @@ """initialize ixdat, giving top-level access to a few of the important structures """ -__version__ = "0.0.6" +__version__ = "0.0.7dev" __title__ = "ixdat" __description__ = "The in-situ experimental data tool" __url__ = "https://github.com/ixdat/ixdat" diff --git a/src/ixdat/readers/__init__.py b/src/ixdat/readers/__init__.py index 33262cb7..530cf18c 100644 --- a/src/ixdat/readers/__init__.py +++ b/src/ixdat/readers/__init__.py @@ -6,14 +6,22 @@ is the reader class for parsing files. """ from ..techniques import TECHNIQUE_CLASSES -from .ec_ms_pkl import EC_MS_CONVERTER -from .zilien import ZilienTSVReader -from .biologic import BiologicMPTReader + +# ixdat from .ixdat_csv import IxdatCSVReader +# potentiostats +from .biologic import BiologicMPTReader +from .autolab import AutolabTXTReader + +# ec-ms +from .zilien import ZilienTSVReader +from .ec_ms_pkl import EC_MS_CONVERTER + READER_CLASSES = { - "EC_MS": EC_MS_CONVERTER, - "zilien": ZilienTSVReader, - "biologic": BiologicMPTReader, "ixdat": IxdatCSVReader, + "biologic": BiologicMPTReader, + "autolab": AutolabTXTReader, + "zilien": ZilienTSVReader, + "EC_MS": EC_MS_CONVERTER, } diff --git a/src/ixdat/readers/autolab.py b/src/ixdat/readers/autolab.py new file mode 100644 index 00000000..ba684fa6 --- /dev/null +++ b/src/ixdat/readers/autolab.py @@ -0,0 +1,70 @@ +"""This module implements the autolab txt reader""" +import re +from pathlib import Path +import pandas as pd +from ..data_series import TimeSeries, ValueSeries +from .reading_tools import prompt_for_tstamp + + +class AutolabTXTReader: + """A reader for ascii files exported by Autolab's Nova software""" + + def read(self, path_to_file, cls=None, name=None, **kwargs): + """read the ascii export from Autolab's Nova software + + Args: + path_to_file (Path): The full abs or rel path including the suffix (.txt) + name (str): The name to use if not the file name + cls (Measurement subclass): The Measurement class to return an object of. + Defaults to `ECMeasurement` and should probably be a subclass thereof in + any case. + **kwargs (dict): Key-word arguments are passed to cls.__init__ + """ + self.path_to_file = Path(path_to_file) + name = name or self.path_to_file.name + tstamp = prompt_for_tstamp(self.path_to_file) + + dataframe = pd.read_csv(self.path_to_file, delimiter=";") + + t_str = "Time (s)" + tseries = TimeSeries( + name=t_str, unit_name="s", data=dataframe[t_str].to_numpy(), tstamp=tstamp + ) + data_series_list = [tseries] + for column_name, series in dataframe.items(): + if column_name == t_str: + continue + data_series_list.append( + ValueSeries( + name=column_name, + unit_name=get_column_unit(column_name), + data=series.to_numpy(), + tseries=tseries, + ) + ) + obj_as_dict = dict( + name=name, + technique="EC", + reader=self, + raw_potential_names=("WE(1).Potential (V)",), + raw_current_names=("WE(1).Current (A)",), + series_list=data_series_list, + tstamp=tstamp, + ) + obj_as_dict.update(kwargs) + + if not cls: + from ..techniques.ec import ECMeasurement + + cls = ECMeasurement + return cls.from_dict(obj_as_dict) + + +def get_column_unit(column_name): + """Return the unit name of an autolab column, i.e the last part of the name in ()""" + unit_match = re.search(r"\((.+)\)$", column_name) + if unit_match: + unit_name = unit_match.group(1) + else: + unit_name = None + return unit_name diff --git a/src/ixdat/readers/biologic.py b/src/ixdat/readers/biologic.py index e345f620..a832f2b7 100644 --- a/src/ixdat/readers/biologic.py +++ b/src/ixdat/readers/biologic.py @@ -5,20 +5,16 @@ from pathlib import Path import re -import time import numpy as np from . import TECHNIQUE_CLASSES from ..data_series import TimeSeries, ValueSeries from ..exceptions import ReadError +from .reading_tools import timestamp_string_to_tstamp ECMeasurement = TECHNIQUE_CLASSES["EC"] delim = "\t" t_str = "time/s" -timestamp_form_strings = [ - "%m/%d/%Y %H:%M:%S", # like 07/29/2020 10:31:03 - "%m-%d-%Y %H:%M:%S", # like 01-31-2020 10:32:02 -] regular_expressions = { "N_header_lines": "Nb header lines : (.+)\n", "timestamp_string": "Acquisition started on : (.+)\n", @@ -91,7 +87,11 @@ def read(self, path_to_file, name=None, cls=None, **kwargs): Args: path_to_file (Path): The full abs or rel path including the ".mpt" extension - **kwargs (dict): Key-word arguments are passed to ECMeasurement.__init__ + name (str): The name to use if not the file name + cls (Measurement subclass): The Measurement class to return an object of. + Defaults to `ECMeasurement` and should probably be a subclass thereof in + any case. + **kwargs (dict): Key-word arguments are passed to cls.__init__ """ path_to_file = Path(path_to_file) if path_to_file else self.path_to_file if self.file_has_been_read: @@ -175,7 +175,9 @@ def process_header_line(self, line): timestamp_match = re.search(regular_expressions["timestamp_string"], line) if timestamp_match: self.timestamp_string = timestamp_match.group(1) - self.tstamp = timestamp_string_to_tstamp(self.timestamp_string) + self.tstamp = timestamp_string_to_tstamp( + self.timestamp_string, forms=BIOLOGIC_TIMESTAMP_FORMS + ) return loop_match = re.search(regular_expressions["loop"], line) if loop_match: @@ -230,27 +232,10 @@ def get_column_unit(column_name): return unit_name -def timestamp_string_to_tstamp(timestamp_string, form=None): - """Return the unix timestamp as a float by parsing timestamp_string - - Args: - timestamp_string (str): The timestamp as read in the .mpt file - form (str): The format string used by time.strptime (string-parse time) - TODO: EC-Lab saves time in a couple different ways based on version and - location. In the future this function will need to try multiple forms. - """ - timestamp_forms = ([form] if form else []) + timestamp_form_strings - for form in timestamp_forms: - try: - struct = time.strptime(timestamp_string, form) - except ValueError: - continue - else: - break - - tstamp = time.mktime(struct) - return tstamp - +BIOLOGIC_TIMESTAMP_FORMS = ( + "%m/%d/%Y %H:%M:%S", # like 07/29/2020 10:31:03 + "%m-%d-%Y %H:%M:%S", # like 01-31-2020 10:32:02 +) # This tuple contains variable names encountered in .mpt files. The tuple can be used by # other modules to tell which data is from biologic. diff --git a/src/ixdat/readers/reading_tools.py b/src/ixdat/readers/reading_tools.py index ac286cd6..6d2d8a54 100644 --- a/src/ixdat/readers/reading_tools.py +++ b/src/ixdat/readers/reading_tools.py @@ -1,11 +1,89 @@ """Module with possibly general-use tools for readers""" +from pathlib import Path +import time import urllib.request from ..config import CFG +STANDARD_TIMESTAMP_FORM = "%m/%d/%Y %H:%M:%S" + + +def timestamp_string_to_tstamp( + timestamp_string, + form=None, + forms=(STANDARD_TIMESTAMP_FORM,), +): + """Return the unix timestamp as a float by parsing timestamp_string + + Args: + timestamp_string (str): The timestamp as read in the .mpt file + form (str): The format string used by time.strptime (string-parse time). This is + optional and overrides `forms` if given. + forms (list of str): The formats you want to try for time.strptime, defaults to + the standard timestamp form. + """ + if form: + forms = (form,) + struct = None + for form in forms: + try: + struct = time.strptime(timestamp_string, form) + continue + except ValueError: + continue + + tstamp = time.mktime(struct) + return tstamp + + +def prompt_for_tstamp(path_to_file, default="creation", form=STANDARD_TIMESTAMP_FORM): + """Return the tstamp resulting from a prompt to enter a timestamp, or a default + + Args: + path_to_file (Path): The file of the measurement that we're getting a tstamp for + default (str or float): What to use as the tstamp if the user does not enter one. + This can be a tstamp as a float, or "creation" to use the file creation time, + or "now" to use `time.time()`. + form (str): The specification string for the timestamp format. Defaults to + `ixdat.readers.reading_tools.STANDARD_TIMESTAMP_FORM` + """ + path_to_file = Path(path_to_file) + + if default == "creation": + default_tstamp = path_to_file.stat().st_mtime + elif default == "now": + default_tstamp = time.time() + elif type(default) in (int, float): + default_tstamp = default + else: + raise TypeError("`default` must be a number or 'creation' or 'now'.") + default_timestring = time.strftime(form, time.localtime(default_tstamp)) + + tstamp = None + timestamp_string = "Try at least once." + while timestamp_string: + timestamp_string = input( + f"Please input the timestamp for the measurement at {path_to_file}.\n" + f"Please use the format {form}.\n" + f"Enter nothing to use the default default," + f" '{default}', which is '{default_timestring}'." + ) + if timestamp_string: + try: + tstamp = time.mktime(time.strptime(timestamp_string, form)) + except ValueError: + print( + f"Could not parse '{timestamp_string}' according as '{form}'.\n" + f"Try again or enter nothing to use the default." + ) + else: + break + return tstamp or default_tstamp + + def url_to_file(url, file_name="temp", directory=None): - """Copy the contents of the url to a file and return its Path.""" + """Copy the contents of the url to a temporary file and return that file's Path.""" directory = directory or CFG.ixdat_temp_dir suffix = "." + str(url).split(".")[-1] path_to_file = (directory / file_name).with_suffix(suffix) From 543c79bd60b64414180e539807a58e4c70174e4d Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Tue, 9 Mar 2021 23:28:26 +0000 Subject: [PATCH 033/118] write ivium reader and organize things a bit --- .../test_autolab_reader.py} | 0 .../reader_testers/test_ivium_reader.py | 20 +++ .../test_zilien_reader.py} | 0 src/ixdat/backends/directory_backend.py | 2 + src/ixdat/measurements.py | 80 +++++++++- src/ixdat/plotters/base_mpl_plotter.py | 3 +- src/ixdat/readers/__init__.py | 10 +- src/ixdat/readers/autolab.py | 5 +- src/ixdat/readers/ivium.py | 140 ++++++++++++++++++ src/ixdat/readers/pfeiffer.py | 5 + src/ixdat/readers/reading_tools.py | 7 +- 11 files changed, 260 insertions(+), 12 deletions(-) rename development_scripts/{ec_tools_dev.py => reader_testers/test_autolab_reader.py} (100%) create mode 100644 development_scripts/reader_testers/test_ivium_reader.py rename development_scripts/{ec_tools.py => reader_testers/test_zilien_reader.py} (100%) create mode 100644 src/ixdat/readers/ivium.py create mode 100644 src/ixdat/readers/pfeiffer.py diff --git a/development_scripts/ec_tools_dev.py b/development_scripts/reader_testers/test_autolab_reader.py similarity index 100% rename from development_scripts/ec_tools_dev.py rename to development_scripts/reader_testers/test_autolab_reader.py diff --git a/development_scripts/reader_testers/test_ivium_reader.py b/development_scripts/reader_testers/test_ivium_reader.py new file mode 100644 index 00000000..699e9177 --- /dev/null +++ b/development_scripts/reader_testers/test_ivium_reader.py @@ -0,0 +1,20 @@ +from pathlib import Path +import pandas as pd +from matplotlib import pyplot as plt + +from ixdat.techniques import CyclicVoltammagram + +path_to_file = Path.home() / ( + "Dropbox/ixdat_resources/test_data/ivium/ivium_test_dataset" +) +path_to_single_file = path_to_file.parent / (path_to_file.name + "_1") +df = pd.read_csv(path_to_single_file, sep=r"\s+", header=1) + +meas = CyclicVoltammagram.read(path_to_file, reader="ivium") + +meas.save() + +meas.plot_measurement() +meas.redefine_cycle(start_potential=0.4, redox=False) +for i in range(4): + meas[i].plot() diff --git a/development_scripts/ec_tools.py b/development_scripts/reader_testers/test_zilien_reader.py similarity index 100% rename from development_scripts/ec_tools.py rename to development_scripts/reader_testers/test_zilien_reader.py diff --git a/src/ixdat/backends/directory_backend.py b/src/ixdat/backends/directory_backend.py index 8d5bc040..e5dbced7 100644 --- a/src/ixdat/backends/directory_backend.py +++ b/src/ixdat/backends/directory_backend.py @@ -18,6 +18,8 @@ def fix_name_for_saving(name): """Replace problematic characters in name with the substitutions defined above""" + if not isinstance(name, str): + return for bad_char, substitution in char_substitutions.items(): name = name.replace(bad_char, substitution) return name diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index 4f35c24a..c49326b4 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -167,6 +167,77 @@ def read_url(cls, url, reader, **kwargs): path_to_temp_file.unlink() return measurement + @classmethod + def from_component_measurements( + cls, component_measurements, keep_originals=True, **kwargs + ): + """Return a measurement with the data contained in the component measurements + + TODO: This function "builds" the resulting measurement, i.e. it appends series + of the same name rather than keeping all the original copies. This should be + made more explicit, and a `build()` method should take over some of the work. + + Args: + component_measurements (list of Measurement) + keep_originals: Whether to keep a list of component_measurements referenced. + This may result in redundant numpy arrays in RAM. + kwargs: key-word arguments are added to the dictionary for cls.from_dict() + + Returns cls: a Measurement object of the + """ + + # First prepare everything but the series_list in the object dictionary + obj_as_dict = component_measurements[0].as_dict() + obj_as_dict.update(kwargs) + del obj_as_dict["m_ids"], obj_as_dict["s_ids"] + if keep_originals: + obj_as_dict["component_measurements"] = component_measurements + + # Now, prepare the built series. First, we loop through the component + # measurements and get all the data and metadata organized in a dictionary: + series_as_dicts = {} + tstamp = component_measurements[0].tstamp + for meas in component_measurements: + tstamp_i = meas.tstamp # save this for later. + meas.tstamp = tstamp # so that the time vectors share a t=0 + for s_name in meas.series_names: + series = meas[s_name] + if s_name in series_as_dicts: + series_as_dicts[s_name]["data"] = np.append( + series_as_dicts[s_name]["data"], series.data + ) + else: + series_as_dicts[s_name] = series.as_dict() + series_as_dicts[s_name]["data"] = series.data + if isinstance(series, ValueSeries): + # This will serve to match it to a TimeSeries later: + series_as_dicts[s_name]["t_name"] = series.tseries.name + meas.tstamp = tstamp_i # so it's not changed in the outer scope + + # Now we make DataSeries, starting with all the TimeSeries + tseries_dict = {} + for name, s_as_dict in series_as_dicts.items(): + if "tstamp" in s_as_dict: + tseries_dict[name] = TimeSeries.from_dict(s_as_dict) + # And then ValueSeries, and put both in with the TimeSeries + series_list = [] + for name, s_as_dict in series_as_dicts.items(): + if name in tseries_dict: + series_list.append(tseries_dict[name]) + elif "t_name" in s_as_dict: + tseries = tseries_dict[s_as_dict["t_name"]] + vseries = ValueSeries( + name=name, + data=s_as_dict["data"], + unit_name=s_as_dict["unit_name"], + tseries=tseries, + ) + series_list.append(vseries) + + # Finally, add this series to the dictionary representation and return the object + obj_as_dict["series_list"] = series_list + return cls.from_dict(obj_as_dict) + @property def metadata_json_string(self): """Measurement metadata as a JSON-formatted string""" @@ -240,6 +311,11 @@ def value_series(self): series for series in self.series_list if isinstance(series, ValueSeries) ] + @property + def time_names(self): + """List of the names of the VSeries in the measurement's DataSeries""" + return set([tseries.name for tseries in self.time_series]) + @property def time_series(self): """List of the TSeries in the measurement's DataSeries. NOT timeshifted!""" @@ -347,9 +423,7 @@ def integrate(self, item, tspan=None, ax=None): ax = self.plotter.new_ax(ylabel=item) # FIXME: xlabel=self[item].tseries.name gives a problem :( ax.plot(t, v, color="k", label=item) - ax.fill_between( - t, v, np.zeros(t.shape), where=v > 0, color="g", alpha=0.3 - ) + ax.fill_between(t, v, np.zeros(t.shape), where=v > 0, color="g", alpha=0.3) ax.fill_between( t, v, np.zeros(t.shape), where=v < 0, color="g", alpha=0.1, hatch="//" ) diff --git a/src/ixdat/plotters/base_mpl_plotter.py b/src/ixdat/plotters/base_mpl_plotter.py index 1abb378a..1e7ff8cb 100644 --- a/src/ixdat/plotters/base_mpl_plotter.py +++ b/src/ixdat/plotters/base_mpl_plotter.py @@ -2,11 +2,10 @@ class MPLPlotter: - def new_ax(self, xlabel=None, ylabel=None): fig, ax = plt.subplots() if xlabel: ax.set_xlabel(xlabel) if ylabel: ax.set_ylabel(ylabel) - return ax \ No newline at end of file + return ax diff --git a/src/ixdat/readers/__init__.py b/src/ixdat/readers/__init__.py index 530cf18c..a01d27c7 100644 --- a/src/ixdat/readers/__init__.py +++ b/src/ixdat/readers/__init__.py @@ -12,7 +12,11 @@ # potentiostats from .biologic import BiologicMPTReader -from .autolab import AutolabTXTReader +from .autolab import NovaASCIIReader +from .ivium import IviumDatasetReader + +# mass spectrometers +from .pfeiffer import PVMassSpecReader # ec-ms from .zilien import ZilienTSVReader @@ -21,7 +25,9 @@ READER_CLASSES = { "ixdat": IxdatCSVReader, "biologic": BiologicMPTReader, - "autolab": AutolabTXTReader, + "autolab": NovaASCIIReader, + "ivium": IviumDatasetReader, + "pfeiffer": PVMassSpecReader, "zilien": ZilienTSVReader, "EC_MS": EC_MS_CONVERTER, } diff --git a/src/ixdat/readers/autolab.py b/src/ixdat/readers/autolab.py index ba684fa6..8cfe6b81 100644 --- a/src/ixdat/readers/autolab.py +++ b/src/ixdat/readers/autolab.py @@ -1,4 +1,5 @@ -"""This module implements the autolab txt reader""" +"""This module implements the reader for ascii exports from autolab's Nova software""" + import re from pathlib import Path import pandas as pd @@ -6,7 +7,7 @@ from .reading_tools import prompt_for_tstamp -class AutolabTXTReader: +class NovaASCIIReader: """A reader for ascii files exported by Autolab's Nova software""" def read(self, path_to_file, cls=None, name=None, **kwargs): diff --git a/src/ixdat/readers/ivium.py b/src/ixdat/readers/ivium.py new file mode 100644 index 00000000..31ffa429 --- /dev/null +++ b/src/ixdat/readers/ivium.py @@ -0,0 +1,140 @@ +"""This module implements the reader for the text export of Ivium's software""" + +import re +from pathlib import Path +import pandas as pd +from .reading_tools import timestamp_string_to_tstamp +from ..data_series import TimeSeries, ValueSeries + + +class IviumDataReader: + def read(self, path_to_file, cls=None, name=None, **kwargs): + """read the ascii export from the Ivium software + + Args: + path_to_file (Path): The full abs or rel path including the suffix (.txt) + name (str): The name to use if not the file name + cls (Measurement subclass): The Measurement class to return an object of. + Defaults to `ECMeasurement`. + **kwargs (dict): Key-word arguments are passed to cls.__init__ + + Returns: + cls: technique measurement object with the ivium data + """ + self.path_to_file = Path(path_to_file) + name = name or self.path_to_file.name + + with open(path_to_file, "r") as f: + timesting_line = f.readline() # we need this for tstamp + columns_line = f.readline() # we need this to get the column names + first_data_line = f.readline() # we need this to check the column names + tstamp = timestamp_string_to_tstamp( + timesting_line.strip(), + form="%d/%m/%Y %H:%M:%S", # like '04/03/2021 19:42:30' + ) + + # ivium files do something really dumb. They add an extra column of data, which + # looks like the measured potential (to complement 'E/V' which is presumably the + # setpoint), but don't add the name of this column in the column name line. + # So in order for pandas' csv reader to read it, we need assign a name to this + # extra column (it becomes 'Unlabeled_1') and specify the column names. + # Here we prepare the thus-corrected column name list, `column_names`: + column_names = [col.strip() for col in columns_line.split(" ") if col.strip()] + first_dat = [dat.strip() for dat in first_data_line.split(" ") if dat.strip()] + if len(first_dat) > len(column_names): + for i in range(len(first_dat) - len(column_names)): + column_names.append(f"unlabeled_{i}") + + # And now we can read the data. Notice also the variable whitespace delimiter. + dataframe = pd.read_csv( + self.path_to_file, delimiter=r"\s+", header=1, names=column_names + ) + + # All that's left is getting the data from the dataframe into DataSeries and + # into the Measurement, starting with the TimeSeries: + t_str = "time/s" + tseries = TimeSeries( + name=t_str, unit_name="s", data=dataframe[t_str].to_numpy(), tstamp=tstamp + ) + data_series_list = [tseries] + for column_name, series in dataframe.items(): + if column_name == t_str: + continue + data_series_list.append( + ValueSeries( + name=column_name, + unit_name=get_column_unit(column_name), + data=series.to_numpy(), + tseries=tseries, + ) + ) + # With the `series_list` ready, we prepare the Measurement dictionary and + # return the Measurement object: + obj_as_dict = dict( + name=name, + technique="EC", + reader=self, + raw_potential_names=("E/V",), + raw_current_names=("I/A",), + series_list=data_series_list, + tstamp=tstamp, + ) + obj_as_dict.update(kwargs) + + if not cls: + from ..techniques.ec import ECMeasurement + + cls = ECMeasurement + return cls.from_dict(obj_as_dict) + + +class IviumDatasetReader: + def read(self, path_to_file, cls=None, name=None, **kwargs): + """Return a Measurement containing the data of an ivium dataset, + + An ivium dataset is a group of ivium files exported together. They share a + folder and a base name, and are suffixed "_1", "_2", etc. + + Args: + path_to_file (Path or str): `Path(path_to_file).parent` is interpreted as the + folder where the files of the ivium dataset is. `Path(path_to_file).name` + up to the first "_" is interpreted as the shared start of the files in + the dataset. You can thus use the base name of the exported files or + the full path of any one of them. + cls (Measurement class): The measurement class. Defaults to ECMeasurement. + name (str): The name of the dataset. Defaults to the base name of the dataset + kwargs: key-word arguments are included in the dictionary for cls.from_dict() + + Returns cls or ECMeasurement: a measurement object with the ivium data + """ + self.path_to_file = Path(path_to_file) + + folder = self.path_to_file.parent + base_name = self.path_to_file.name + if re.search(r"_[0-9]", base_name): + base_name = base_name.rpartition("_")[0] + name = name or base_name + + # With two list comprehensions, we get the Measurement object for each file + # in the folder who's name starts with base_name: + all_file_paths = [f for f in folder.iterdir() if f.name.startswith(base_name)] + component_measurements = [ + IviumDataReader().read(f, cls=cls) for f in all_file_paths + ] + + # Now we append these using the from_component_measurements class method of the + # right TechniqueMeasurement class, and return the result. + if not cls: + from ..techniques.ec import ECMeasurement + + cls = ECMeasurement + measurement = cls.from_component_measurements( + component_measurements, name=name, **kwargs + ) + return measurement + + +def get_column_unit(column_name): + """Return the unit name of an ivium column, i.e what follows the first '/'.""" + if "/" in column_name: + return column_name.split("/", 1)[1] diff --git a/src/ixdat/readers/pfeiffer.py b/src/ixdat/readers/pfeiffer.py new file mode 100644 index 00000000..fdd1da34 --- /dev/null +++ b/src/ixdat/readers/pfeiffer.py @@ -0,0 +1,5 @@ +"""This module implements the reader for Pfeiffer Vacuum's PV Mass Spec software""" + + +class PVMassSpecReader: + pass diff --git a/src/ixdat/readers/reading_tools.py b/src/ixdat/readers/reading_tools.py index 6d2d8a54..f3b59b7d 100644 --- a/src/ixdat/readers/reading_tools.py +++ b/src/ixdat/readers/reading_tools.py @@ -6,7 +6,8 @@ from ..config import CFG -STANDARD_TIMESTAMP_FORM = "%m/%d/%Y %H:%M:%S" +STANDARD_TIMESTAMP_FORM = "%d/%m/%Y %H:%M:%S" # like '31/12/2020 23:59:59' +USA_TIMESTAMP_FORM = "%m/%d/%Y %H:%M:%S" # like '12/31/2020 23:59:59' def timestamp_string_to_tstamp( @@ -20,7 +21,7 @@ def timestamp_string_to_tstamp( timestamp_string (str): The timestamp as read in the .mpt file form (str): The format string used by time.strptime (string-parse time). This is optional and overrides `forms` if given. - forms (list of str): The formats you want to try for time.strptime, defaults to + forms (iter of str): The formats you want to try for time.strptime, defaults to the standard timestamp form. """ if form: @@ -37,7 +38,7 @@ def timestamp_string_to_tstamp( return tstamp -def prompt_for_tstamp(path_to_file, default="creation", form=STANDARD_TIMESTAMP_FORM): +def prompt_for_tstamp(path_to_file, default="creation", form=USA_TIMESTAMP_FORM): """Return the tstamp resulting from a prompt to enter a timestamp, or a default Args: From ca8813fec58d64a96672d9d9d90cf8bd27f2e7ec Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Wed, 10 Mar 2021 21:16:46 +0000 Subject: [PATCH 034/118] pfeiffer reader for ms and dataframe function --- .../reader_testers/test_ixdat_csv_reader.py | 8 ++ .../reader_testers/test_pfeiffer_reader.py | 20 +++++ src/ixdat/readers/autolab.py | 20 +---- src/ixdat/readers/ivium.py | 21 +----- src/ixdat/readers/pfeiffer.py | 74 +++++++++++++++++++ src/ixdat/readers/reading_tools.py | 32 ++++++++ src/ixdat/techniques/ms.py | 54 +++++++++++++- 7 files changed, 192 insertions(+), 37 deletions(-) create mode 100644 development_scripts/reader_testers/test_ixdat_csv_reader.py create mode 100644 development_scripts/reader_testers/test_pfeiffer_reader.py diff --git a/development_scripts/reader_testers/test_ixdat_csv_reader.py b/development_scripts/reader_testers/test_ixdat_csv_reader.py new file mode 100644 index 00000000..4aa6d45c --- /dev/null +++ b/development_scripts/reader_testers/test_ixdat_csv_reader.py @@ -0,0 +1,8 @@ +from ixdat import Measurement + +meas = Measurement.read_url( + "https://raw.githubusercontent.com/ixdat/tutorials/" + + "main/loading_appending_and_saving/co_strip.csv", + reader="ixdat", +) +meas.plot_measurement() diff --git a/development_scripts/reader_testers/test_pfeiffer_reader.py b/development_scripts/reader_testers/test_pfeiffer_reader.py new file mode 100644 index 00000000..30fa99f7 --- /dev/null +++ b/development_scripts/reader_testers/test_pfeiffer_reader.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +""" +Created on Mon Jan 25 22:02:57 2021 + +@author: scott +""" +from pathlib import Path +from matplotlib import pyplot as plt + +from ixdat import Measurement + +path_to_file = ( + Path.home() + / ("Dropbox/ixdat_resources/test_data/pfeiffer") + / "MID_air, Position 1, RGA PrismaPro 200 44526001, 003-02-2021 17'41'12 - Bin.dat" +) + +meas = Measurement.read(path_to_file, reader="pfeiffer") + +meas.plot_measurement() diff --git a/src/ixdat/readers/autolab.py b/src/ixdat/readers/autolab.py index 8cfe6b81..01c9e6d3 100644 --- a/src/ixdat/readers/autolab.py +++ b/src/ixdat/readers/autolab.py @@ -3,8 +3,7 @@ import re from pathlib import Path import pandas as pd -from ..data_series import TimeSeries, ValueSeries -from .reading_tools import prompt_for_tstamp +from .reading_tools import prompt_for_tstamp, series_list_from_dataframe class NovaASCIIReader: @@ -27,22 +26,9 @@ def read(self, path_to_file, cls=None, name=None, **kwargs): dataframe = pd.read_csv(self.path_to_file, delimiter=";") - t_str = "Time (s)" - tseries = TimeSeries( - name=t_str, unit_name="s", data=dataframe[t_str].to_numpy(), tstamp=tstamp + data_series_list = series_list_from_dataframe( + dataframe, "Time (s)", tstamp, get_column_unit ) - data_series_list = [tseries] - for column_name, series in dataframe.items(): - if column_name == t_str: - continue - data_series_list.append( - ValueSeries( - name=column_name, - unit_name=get_column_unit(column_name), - data=series.to_numpy(), - tseries=tseries, - ) - ) obj_as_dict = dict( name=name, technique="EC", diff --git a/src/ixdat/readers/ivium.py b/src/ixdat/readers/ivium.py index 31ffa429..8d81d960 100644 --- a/src/ixdat/readers/ivium.py +++ b/src/ixdat/readers/ivium.py @@ -3,8 +3,7 @@ import re from pathlib import Path import pandas as pd -from .reading_tools import timestamp_string_to_tstamp -from ..data_series import TimeSeries, ValueSeries +from .reading_tools import timestamp_string_to_tstamp, series_list_from_dataframe class IviumDataReader: @@ -52,22 +51,10 @@ def read(self, path_to_file, cls=None, name=None, **kwargs): # All that's left is getting the data from the dataframe into DataSeries and # into the Measurement, starting with the TimeSeries: - t_str = "time/s" - tseries = TimeSeries( - name=t_str, unit_name="s", data=dataframe[t_str].to_numpy(), tstamp=tstamp + + data_series_list = series_list_from_dataframe( + dataframe, "time/s", tstamp, get_column_unit ) - data_series_list = [tseries] - for column_name, series in dataframe.items(): - if column_name == t_str: - continue - data_series_list.append( - ValueSeries( - name=column_name, - unit_name=get_column_unit(column_name), - data=series.to_numpy(), - tseries=tseries, - ) - ) # With the `series_list` ready, we prepare the Measurement dictionary and # return the Measurement object: obj_as_dict = dict( diff --git a/src/ixdat/readers/pfeiffer.py b/src/ixdat/readers/pfeiffer.py index fdd1da34..b065197d 100644 --- a/src/ixdat/readers/pfeiffer.py +++ b/src/ixdat/readers/pfeiffer.py @@ -1,5 +1,79 @@ """This module implements the reader for Pfeiffer Vacuum's PV Mass Spec software""" +import re +from pathlib import Path +import pandas as pd +from .reading_tools import timestamp_string_to_tstamp, series_list_from_dataframe +from ..techniques import MSMeasurement + class PVMassSpecReader: + """A reader for (advanced) MID files exported from PVMassSpec ('... - Bin.dat')""" + + def read(self, path_to_file, cls=None, name=None, **kwargs): + """Return a Measurement with the (advanced) MID data in the PVMassSpec file + + Args: + path_to_file (Path or str): a path to the file exported by PVMassSpec with + (advanced) MID data. This file is typically exported with a name that + ends in '- Bin.dat', and with the timestamp in the file name. Note + that the file can be renamed, as the original name is in the file, + and the timestamp is read from there. + cls (Measurement subclass): The technique class of which to return an object. + Defaults to MSMeasurement. + name (str): The name of the measurement. Defaults to Path(path_to_file).name + kwargs: key-word args are used to initiate the measurement via cls.as_dict() + + Return cls: The measurement object + """ + self.path_to_file = Path(path_to_file) + name = name or self.path_to_file.name + with open(path_to_file, "r") as f: + # timestamp is on the the third line, which we select here: + tstamp_line = [f.readline() for _ in range(3)][-1] + tstamp = timestamp_string_to_tstamp( + tstamp_line.split(".")[-2][-19:], # last 19 characters before the last '.' + form="%m-%d-%Y %H'%M'%S", # like "03-02-2021 12'58'40" + ) + df = pd.read_csv(self.path_to_file, header=6, delimiter="\t") + # PV MassSpec calls masses _amu, information we need to pass on to + # MSMeasurement, so that the data will be accessible by the 'M' mass string. + mass_aliases = { + mass_from_column_name(key): key for key in df.keys() if key.endswith("_amu") + } + series_list = series_list_from_dataframe( + df, + tstamp=tstamp, + t_str="Time Relative (sec)", + unit_finding_function=get_column_unit, + ) + meas_as_dict = { + "name": name, + "tstamp": tstamp, + "series_list": series_list, + "mass_aliases": mass_aliases, + "technique": "MS", + } + meas_as_dict.update(kwargs) + cls = cls or MSMeasurement + return cls.from_dict(meas_as_dict) + + +class PVMassSpecScanReader: + """A reader for mass spectra files exported from PVMassSpec ('... - Scan.dat')""" + pass + + +def mass_from_column_name(mass): + """Return the PVMassSpec mass 'M' given the column name '_amu' as string""" + return f"M{mass[:-4]}" + + +def get_column_unit(column_name): + """Return the unit name of an ivium column, i.e what follows the first '/'.""" + unit_match = re.search(r"\((.*)\)$", column_name) + if unit_match: + return unit_match.group(1) + elif "amu" in column_name: + return "A" diff --git a/src/ixdat/readers/reading_tools.py b/src/ixdat/readers/reading_tools.py index f3b59b7d..df3c4bd0 100644 --- a/src/ixdat/readers/reading_tools.py +++ b/src/ixdat/readers/reading_tools.py @@ -4,6 +4,7 @@ import time import urllib.request from ..config import CFG +from ..measurements import TimeSeries, ValueSeries STANDARD_TIMESTAMP_FORM = "%d/%m/%Y %H:%M:%S" # like '31/12/2020 23:59:59' @@ -83,6 +84,37 @@ def prompt_for_tstamp(path_to_file, default="creation", form=USA_TIMESTAMP_FORM) return tstamp or default_tstamp +def series_list_from_dataframe(dataframe, t_str, tstamp, unit_finding_function): + """Return a list of DataSeries with the data in a pandas dataframe. + + Args: + dataframe (pandas dataframe): The dataframe. Column names are used as series + names, data is taken with series.to_numpy(). The dataframe can only have one + TimeSeries (if there are more than one, pandas is probably not the right + format anyway, since it requires columns be the same length). + t_str (str): The name of the column to use as the TimeSeries + tstamp (float): The timestamp + unit_finding_function (function): A function which takes a column name as a + string and returns its unit. + """ + tseries = TimeSeries( + name=t_str, unit_name="s", data=dataframe[t_str].to_numpy(), tstamp=tstamp + ) + data_series_list = [tseries] + for column_name, series in dataframe.items(): + if column_name == t_str: + continue + data_series_list.append( + ValueSeries( + name=column_name, + unit_name=unit_finding_function(column_name), + data=series.to_numpy(), + tseries=tseries, + ) + ) + return data_series_list + + def url_to_file(url, file_name="temp", directory=None): """Copy the contents of the url to a temporary file and return that file's Path.""" directory = directory or CFG.ixdat_temp_dir diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index 8e451068..131e6744 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -1,6 +1,8 @@ """Module for representation and analysis of MS measurements""" from ..measurements import Measurement +from ..plotters.ms_plotter import MSPlotter +from ..exceptions import SeriesNotFoundError import re import numpy as np @@ -8,15 +10,35 @@ class MSMeasurement(Measurement): """Class implementing raw MS functionality""" - def __init__(self, name, **kwargs): + extra_column_attrs = { + "ms_meaurements": { + "mass_aliases", + } + } + + def __init__(self, name, mass_aliases=None, **kwargs): """Initializes a MS Measurement Args: name (str): The name of the measurement calibration (dict): calibration constants whereby the key - corresponds to the respective signal name.""" + corresponds to the respective signal name. + mass_aliases (dict): {mass: mass_name} for any masses that + do not have the standard 'M' format used by ixdat. + """ super().__init__(name, **kwargs) self.calibration = None # TODO: Not final implementation + self.mass_aliases = mass_aliases or {} + + def __getitem__(self, item): + """Adds to Measurement's lookup to check if item is an alias for a mass""" + try: + return super().__getitem__(item) + except SeriesNotFoundError: + if item in self.mass_aliases: + return self[self.mass_aliases[item]] + else: + raise def grab_signal(self, signal_name, tspan=None, t_bg=None): """Returns raw signal for a given signal name @@ -58,4 +80,30 @@ def grab_cal_signal(self, signal_name, tspan=None, t_bg=None): @property def mass_list(self): """List of the masses for which ValueSeries are contained in the measurement""" - return [col for col in self.series_names if re.search("^M[0-9]+$", col)] + return [self.as_mass(col) for col in self.series_names if self.is_mass(col)] + + def is_mass(self, item): + if re.search("^M[0-9]+$", item): + return True + if item in self.mass_aliases.values(): + return True + return False + + def as_mass(self, item): + if re.search("^M[0-9]+$", item): + return item + else: + try: + return next(k for k, v in self.mass_aliases.items() if v == item) + except StopIteration: + raise TypeError(f"{self} does not recognize '{item}' as a mass.") + + @property + def plotter(self): + """The default plotter for ECMeasurement is ECPlotter""" + if not self._plotter: + from ..plotters.ec_plotter import ECPlotter + + self._plotter = MSPlotter(measurement=self) + + return self._plotter From 52f0f7e4e82c7599dfb909e39c079c5b3ce6844a Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Wed, 10 Mar 2021 23:07:00 +0000 Subject: [PATCH 035/118] implement review/debugging from PR #8 --- src/ixdat/measurements.py | 8 ++++---- src/ixdat/techniques/cv.py | 40 +++++++++++++++++++++++++++++++------- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index c49326b4..6c06dd0a 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -365,7 +365,7 @@ def __delitem__(self, series_name): new_series_list.append(s) self._series_list = new_series_list - def grab(self, item, tspan=None, include_endpoints=True): + def grab(self, item, tspan=None, include_endpoints=False): """Return a value vector with the corresponding time vector Grab is the *canonical* way to retrieve numerical time-dependent data from a @@ -385,8 +385,8 @@ def grab(self, item, tspan=None, include_endpoints=True): tspan (iter of float): Defines the timespan with its first and last values. Optional. By default the entire time of the measurement is included. include_endpoints (bool): Whether to add a points at t = tspan[0] and - t = tspan[-1] to the data returned. Default is True. This makes - trapezoidal integration less dependent on the time resolution. + t = tspan[-1] to the data returned. This makes trapezoidal integration + less dependent on the time resolution. Default is False. """ vseries = self[item] tseries = vseries.tseries @@ -417,7 +417,7 @@ def grab_for_t(self, item, t): def integrate(self, item, tspan=None, ax=None): """Return the time integral of item in the specified timespan""" - t, v = self.grab(item, tspan) + t, v = self.grab(item, tspan, include_endpoints=True) if ax: if ax == "new": ax = self.plotter.new_ax(ylabel=item) diff --git a/src/ixdat/techniques/cv.py b/src/ixdat/techniques/cv.py index 368b1765..4226e4d0 100644 --- a/src/ixdat/techniques/cv.py +++ b/src/ixdat/techniques/cv.py @@ -168,20 +168,29 @@ def scan_rate(self, res_points=10): # TODO: cache'ing, index accessibility return scan_rate_series - @property - def sweep_specs(self): + 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", - "steady": "hold", + "zero": "hold", } - indexed_sweeps = find_signed_sections(self.scan_rate.data) + 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( @@ -189,12 +198,21 @@ def sweep_specs(self): ) return timed_sweeps - def diff_with(self, other, v_list=None, cls=None): + 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 (CyclicVoltammagram): The cyclic voltammagram 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 + CyclicVoltammagramDiff. + v_scan_res (float): see CyclicVoltammagram.get_timed_sweeps() + res_points (int): see CyclicVoltammagram.get_timed_sweeps() """ vseries = self.potential @@ -208,10 +226,18 @@ def diff_with(self, other, v_list=None, cls=None): ) my_sweep_specs = [ - spec for spec in self.sweep_specs if spec[1] in ["anodic", "cathodic"] + 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.sweep_specs if spec[1] in ["anodic", "cathodic"] + 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( From d04ea7ff5e89ea4f6ffd891449562c66140c060a Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Thu, 11 Mar 2021 16:51:44 +0000 Subject: [PATCH 036/118] catching up documentation! --- .readthedocs.yml | 9 ++- docs/requirements.txt | 6 ++ docs/source/exporter_docs/index.rst | 12 ++++ docs/source/index.rst | 4 ++ docs/source/introduction.rst | 8 ++- docs/source/measurement.rst | 19 ++++-- docs/source/plotter_docs/index.rst | 53 +++++++++++++++ docs/source/reader_docs/index.rst | 65 +++++++++++++++++++ docs/source/technique_docs/ec_ms.rst | 37 +++++++++++ .../technique_docs/electrochemistry.rst | 34 ++++++++++ docs/source/technique_docs/index.rst | 20 ++++++ docs/source/technique_docs/mass_spec.rst | 16 +++++ src/ixdat/__init__.py | 2 +- src/ixdat/readers/ivium.py | 4 ++ src/ixdat/readers/zilien.py | 2 + 15 files changed, 278 insertions(+), 13 deletions(-) create mode 100644 docs/requirements.txt create mode 100644 docs/source/exporter_docs/index.rst create mode 100644 docs/source/plotter_docs/index.rst create mode 100644 docs/source/reader_docs/index.rst create mode 100644 docs/source/technique_docs/ec_ms.rst create mode 100644 docs/source/technique_docs/electrochemistry.rst create mode 100644 docs/source/technique_docs/index.rst create mode 100644 docs/source/technique_docs/mass_spec.rst diff --git a/.readthedocs.yml b/.readthedocs.yml index 5c05f812..e5612bb8 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -18,8 +18,7 @@ sphinx: # - pdf # Optionally set the version of Python and requirements required to build your docs -# python: -# version: 3.7 -# install: -# - requirements: docs/requirements.txt -# - requirements: docs/requirements.txt \ No newline at end of file +python: + version: 3.6 + install: + - requirements: docs/requirements.txt \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..2a1b2fb5 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,6 @@ +# needed as per https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html + +sphinx +sphinx_rtd_theme +readthedocs-sphinx-search +ixdat \ No newline at end of file diff --git a/docs/source/exporter_docs/index.rst b/docs/source/exporter_docs/index.rst new file mode 100644 index 00000000..4b8f2875 --- /dev/null +++ b/docs/source/exporter_docs/index.rst @@ -0,0 +1,12 @@ +.. _exporters: + +Exporters: getting data out of `ixdat` +====================================== + +.. automodule:: ixdat.exporters.csv_exporter + :members: + +.. automodule:: ixdat.exporters.ec_exporter + :members: + + diff --git a/docs/source/index.rst b/docs/source/index.rst index 61635c88..c6489d15 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -13,7 +13,11 @@ This documentation, like ``ixdat`` itself, is a work in progress and we welcome introduction measurement + technique_docs/index data-series + reader_docs/index + exporter_docs/index + plotter_docs/index license Indices and tables diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst index 3fbbd85a..3e6b6939 100644 --- a/docs/source/introduction.rst +++ b/docs/source/introduction.rst @@ -15,4 +15,10 @@ In addition to a **pluggable** parser interface for importing your data format, We will update this documentation as features are added. ``ixdat`` is free and open source software and we welcome input and new collaborators. -The source is here: https://github.com/ixdat \ No newline at end of file +The source is here: https://github.com/ixdat + + +.. toctree:: + :maxdepth: 2 + + extended-concept \ No newline at end of file diff --git a/docs/source/measurement.rst b/docs/source/measurement.rst index 010cf02f..fa1a986d 100644 --- a/docs/source/measurement.rst +++ b/docs/source/measurement.rst @@ -1,3 +1,5 @@ +.. _measurement: + The measurement structure ========================= @@ -5,7 +7,8 @@ The **measurement** (``meas``) is the central object in the pluggable structure main interface for user interaction. A measurement is an object of the generalized class main interface for user interaction. A measurement is an object of the generalized class ``Measurement``, defined in the ``measurements`` module, or an inheriting -***TechniqueMeasurement*** class defined in a module of the ``techniques`` folder. +***TechniqueMeasurement*** class defined in a module of the ``techniques`` folder +(see :ref:`techniques`_). The general pluggable structure is defined by ``Measurement``, connecting every measurement to a *reader* for importing from text, a *backend* for saving and loading in @@ -18,13 +21,19 @@ exporter, while an ``ixdat`` session will typically work with one backend handle :width: 400 :alt: Design: pluggability +Classes for measurement techniques +---------------------------------- + Inheritance in TechniqueMeasurement classes makes it so that related techniques -can share functionality. +can share functionality. Here is an illustration of the role of inheritence, using +EC, MS, and EC-MS as an example: .. image:: figures/inheritance.svg :width: 400 :alt: Design: inheritance +A full list of TechniqueMeasurements is in :ref:`techniques`_. + Initiating a measurement ------------------------ @@ -35,12 +44,10 @@ by Biologic's EC-Lab, one can type: >>> from ixdat import Measurement >>> ec_meas = Measurement.read("my_file.mpt", reader="biologic") +See :ref:`readers`_ for a description of the available readers. + The biologic reader (``ixdat.readers.biologic.BiologicMPTReader``) ensures that the object returned, ``ec_meas``, is of type ``ECMeasurement``. -A full list of the readers thus accessible and their names can be viewed by typing: - ->>> from ixdat.readers import READER_CLASSES ->>> READER_CLASSES Another workflow starts with loading a measurement from the active ``ixdat`` backend. This can also be done straight from ``Measurement``, as follows: diff --git a/docs/source/plotter_docs/index.rst b/docs/source/plotter_docs/index.rst new file mode 100644 index 00000000..74c4035b --- /dev/null +++ b/docs/source/plotter_docs/index.rst @@ -0,0 +1,53 @@ +.. _plotters: + +Plotters: visualizing `ixdat` data +================================== + +Basic +----- + +The ``base_mpl_plotter`` module +............................... + +.. automodule:: ixdat.plotters.base_mpl_plotter + :members: + +The ``value_plotter`` module +............................... + +.. automodule:: ixdat.plotters.value_plotter + :members: + +Electrochemistry +---------------- + +.. _ec-plotter: + +The ``ec_plotter`` module +............................... + +.. automodule:: ixdat.plotters.ec_plotter + :members: + +Mass Spectrometry +----------------- +.. _ms-plotter: + +The ``ms_plotter`` module +............................... + +.. automodule:: ixdat.plotters.ms_plotter + :members: + +EC-MS +----- + +The ``ecms_plotter`` module +............................... + +.. _ecms-plotter: + +.. automodule:: ixdat.plotters.ecms_plotter + :members: + + diff --git a/docs/source/reader_docs/index.rst b/docs/source/reader_docs/index.rst new file mode 100644 index 00000000..2bb2ae79 --- /dev/null +++ b/docs/source/reader_docs/index.rst @@ -0,0 +1,65 @@ +.. _readers: + +Readers: getting data into `ixdat` +================================== + +A full list of the readers thus accessible and their names can be viewed by typing: + +>>> from ixdat.readers import READER_CLASSES +>>> READER_CLASSES + +Reading .csv files exported by ixdat: The ``IxdatCSVReader`` +------------------------------------------------------------ + +The ``ixdat_csv`` module +........................ + +.. automodule:: ixdat.readers.ixdat_csv + :members: + +Electrochemistry and sub-techniques +------------------------------------ + +The ``biologic`` module +........................ + +.. automodule:: ixdat.readers.biologic + :members: + +The ``autolab`` module +........................ + +.. automodule:: ixdat.readers.autolab + :members: + +The ``ivium`` module +........................ + +.. automodule:: ixdat.readers.ivium + :members: + +Mass Spectrometry and sub-techniques +------------------------------------ + +The ``pfeiffer`` module +........................ + +.. automodule:: ixdat.readers.pfeiffer + :members: + +EC-MS and sub-techniques +------------------------ + +The ``zilien`` module +........................ + +.. automodule:: ixdat.readers.zilien + :members: + +The ``ec_ms_pkl`` module +........................ + +.. automodule:: ixdat.readers.ec_ms_pkl + :members: + + diff --git a/docs/source/technique_docs/ec_ms.rst b/docs/source/technique_docs/ec_ms.rst new file mode 100644 index 00000000..66dedf6c --- /dev/null +++ b/docs/source/technique_docs/ec_ms.rst @@ -0,0 +1,37 @@ +.. _ec-ms: + +Electrochemistry - Mass Spectrometry (EC-MS) +============================================ + +The main class for EC-MS data is the ECMSMeasurement. + +It comes with the EC-MS plotter which makes + +ixdat will soon have all the functionality and more for EC-MS data and analysis as the +legacy `EC_MS `_ package. This includes the tools +behind the EC-MS analysis and visualization in the puplications: + +- Daniel B. Trimarco and Soren B. Scott, et al. **Enabling real-time detection of electrochemical desorption phenomena with sub-monolayer sensitivity**. `Electrochimica Acta, 2018 `_. + +- Claudie Roy, Bela Sebok, Soren B. Scott, et al. **Impact of nanoparticle size and lattice oxygen on water oxidation on NiFeOxHy**. `Nature Catalysis, 2018 `_. + +- Anna Winiwarter and Luca Silvioli, et al. **Towards an Atomistic Understanding of Electrocatalytic Partial Hydrocarbon Oxidation: Propene on Palladium**. `Energy and Environmental Science, 2019 `_. + +- Soren B. Scott and Albert Engstfeld, et al. **Anodic molecular hydrogen formation on Ru and Cu electrodes**. `Catalysis Science & Technology, 2020 `_. + +- Anna Winiwarter, et al. **CO as a Probe Molecule to Study Surface Adsorbates during Electrochemical Oxidation of Propene**. `ChemElectroChem, 2021 `_. + +- Soren B. Scott, et al. **Tracking oxygen atoms in electrochemical CO oxidation –Part I: Oxygen exchange via CO2 hydration**. `Electrochimica Acta, 2021 `_. + +- Soren B. Scott, et al. **Tracking oxygen atoms in electrochemical CO oxidation –Part II: Lattice oxygen reactivity in oxides of Pt and Ir**. `Electrochimica Acta, 2021 `_. + + +The ``ec_ms`` module +-------------------- +.. automodule:: ixdat.techniques.ec_ms + :members: + +The ``deconvolution`` module +---------------------------- +.. automodule:: ixdat.techniques.deconvolution + :members: \ No newline at end of file diff --git a/docs/source/technique_docs/electrochemistry.rst b/docs/source/technique_docs/electrochemistry.rst new file mode 100644 index 00000000..55dd1a4d --- /dev/null +++ b/docs/source/technique_docs/electrochemistry.rst @@ -0,0 +1,34 @@ +.. _electrochemistry: + +Electrochemistry +================ + +The main TechniqueMeasurement class for electrochemistry is the `ECMeasurement`. +Sublcasses of `ECMeasurement` include `CyclicVoltammagram` and `CyclicVoltammagramDiff`. + +Direct-current electrochemsitry measurements (`ixdat` does not yet offer specific +functionality for impedance data) are characterized by the essential quantities being +working-electrode current (in loop with the counter electrode) and potential (vs the +reference electrode) as a function of time. Either current or potential can be controlled +as the input variable, so the other acts at the response, and it is common to plot +current vs potential, but in all cases both are tracked or controlled as a function of +time. This results in the essential variables `t` (time), `v` (potential), and `j` +(current). The main job of `ECMeasurement` and subclasses is to give standardized, +convenient, and powerful access to these three variables for data selection, analysis, +and visualization, regardless of which hardware the data was acquired with. + +The default plotter, :ref:`ECPlotter `, plots these variables. +The default exporter, ECExporter, exports these variables as well as an incrementer for +selecting data, ``cycle``. + +The ``ec`` module +----------------- + +.. automodule:: ixdat.techniques.ec + :members: + +The ``cv`` module +----------------- + +.. automodule:: ixdat.techniques.cv + :members: \ No newline at end of file diff --git a/docs/source/technique_docs/index.rst b/docs/source/technique_docs/index.rst new file mode 100644 index 00000000..990a509b --- /dev/null +++ b/docs/source/technique_docs/index.rst @@ -0,0 +1,20 @@ +.. _technqiues: + +Techniques: ``ixdat``'s measurement subclasses +============================================== + +TechniqueMeasurement classes (interchangeable with Techniques or Measurement subclasses) +inherit from the ``Measurement`` class (see :ref:`measurement`) + +A full list of the techniques and there names is in the ``TECHNIQUE_CLASSES`` dictionary: + +>>> from ixdat.techniques import TECHNIQUE_CLASSES +>>> TECHNIQUE_CLASSES + + +.. toctree:: + :maxdepth: 2 + + electrochemistry + mass_spec + ec_ms diff --git a/docs/source/technique_docs/mass_spec.rst b/docs/source/technique_docs/mass_spec.rst new file mode 100644 index 00000000..323cd3cc --- /dev/null +++ b/docs/source/technique_docs/mass_spec.rst @@ -0,0 +1,16 @@ +.. _mass-spec: + +Mass Spectrometry +================= + +Mass spectrometry is commonly used in catalysis and electrocatalysis for two different +types of data - spectra, where intensity is taken while scanning over m/z, and +mass intensity detectin (MID) where the intensity of a small set of m/z values are +tracked in time. + +The main TechniqueMeasurement class for MID data is the `MSMeasurement`. + +Classes dealing with spectra are under development. + +.. automodule:: ixdat.techniques.ms + :members: \ No newline at end of file diff --git a/src/ixdat/__init__.py b/src/ixdat/__init__.py index c4796f7c..6d900eac 100644 --- a/src/ixdat/__init__.py +++ b/src/ixdat/__init__.py @@ -1,6 +1,6 @@ """initialize ixdat, giving top-level access to a few of the important structures """ -__version__ = "0.0.7dev" +__version__ = "0.0.8" __title__ = "ixdat" __description__ = "The in-situ experimental data tool" __url__ = "https://github.com/ixdat/ixdat" diff --git a/src/ixdat/readers/ivium.py b/src/ixdat/readers/ivium.py index 8d81d960..b7cb6da7 100644 --- a/src/ixdat/readers/ivium.py +++ b/src/ixdat/readers/ivium.py @@ -7,6 +7,8 @@ class IviumDataReader: + """Class for reading single ivium files""" + def read(self, path_to_file, cls=None, name=None, **kwargs): """read the ascii export from the Ivium software @@ -76,6 +78,8 @@ def read(self, path_to_file, cls=None, name=None, **kwargs): class IviumDatasetReader: + """Class for reading sets of ivium files exported together""" + def read(self, path_to_file, cls=None, name=None, **kwargs): """Return a Measurement containing the data of an ivium dataset, diff --git a/src/ixdat/readers/zilien.py b/src/ixdat/readers/zilien.py index 2828631b..8df5cba7 100644 --- a/src/ixdat/readers/zilien.py +++ b/src/ixdat/readers/zilien.py @@ -5,6 +5,8 @@ class ZilienTSVReader: + """Class for reading files saved by Spectro Inlets' Zilien software""" + def read(self, path_to_file, cls=None, name=None, **kwargs): """Read a zilien file From 438d1e6305df3c09d3c6bba6ede57a19e319cc07 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Fri, 12 Mar 2021 15:03:26 +0000 Subject: [PATCH 037/118] improve documentation --- docs/source/exporter_docs/index.rst | 11 + docs/source/extended-concept.rst | 3 +- docs/source/figures/cv_diff.svg | 2689 +++++++++++++++++ docs/source/figures/ec_subplots.svg | 2228 ++++++++++++++ docs/source/figures/ixdat_profile_pic.svg | 2170 +++++++++++++ docs/source/index.rst | 8 +- docs/source/introduction.rst | 28 +- docs/source/measurement.rst | 6 +- docs/source/plotter_docs/index.rst | 2 + docs/source/reader_docs/index.rst | 7 +- docs/source/technique_docs/ec_ms.rst | 4 + .../technique_docs/electrochemistry.rst | 30 +- docs/source/technique_docs/index.rst | 17 +- docs/source/technique_docs/mass_spec.rst | 8 +- 14 files changed, 7188 insertions(+), 23 deletions(-) create mode 100644 docs/source/figures/cv_diff.svg create mode 100644 docs/source/figures/ec_subplots.svg create mode 100644 docs/source/figures/ixdat_profile_pic.svg diff --git a/docs/source/exporter_docs/index.rst b/docs/source/exporter_docs/index.rst index 4b8f2875..40b5ef10 100644 --- a/docs/source/exporter_docs/index.rst +++ b/docs/source/exporter_docs/index.rst @@ -3,9 +3,20 @@ Exporters: getting data out of `ixdat` ====================================== + +Here is an example of an ixdat csv file (exported from a ``CyclicVoltammagram`` +measurement using its default ``ECExporter``.): +https://github.com/ixdat/tutorials/blob/main/loading_appending_and_saving/co_strip.csv + +The ``csv_exporter`` module +........................ + .. automodule:: ixdat.exporters.csv_exporter :members: +The ``ec_exporter`` module +........................ + .. automodule:: ixdat.exporters.ec_exporter :members: diff --git a/docs/source/extended-concept.rst b/docs/source/extended-concept.rst index dd9f532b..341c912f 100644 --- a/docs/source/extended-concept.rst +++ b/docs/source/extended-concept.rst @@ -1,8 +1,7 @@ .. _concept: -================ Extended concept -================ +---------------- *By Soren B. Scott, 20H03 (August 3, 2020)* diff --git a/docs/source/figures/cv_diff.svg b/docs/source/figures/cv_diff.svg new file mode 100644 index 00000000..cdd1ddda --- /dev/null +++ b/docs/source/figures/cv_diff.svg @@ -0,0 +1,2689 @@ + + + + + + + + + 2021-03-12T10:09:46.321242 + image/svg+xml + + + Matplotlib v3.3.2, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/figures/ec_subplots.svg b/docs/source/figures/ec_subplots.svg new file mode 100644 index 00000000..23588824 --- /dev/null +++ b/docs/source/figures/ec_subplots.svg @@ -0,0 +1,2228 @@ + + + + + + + + + 2021-03-12T10:24:24.895500 + image/svg+xml + + + Matplotlib v3.3.2, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/figures/ixdat_profile_pic.svg b/docs/source/figures/ixdat_profile_pic.svg new file mode 100644 index 00000000..a50d3d9a --- /dev/null +++ b/docs/source/figures/ixdat_profile_pic.svg @@ -0,0 +1,2170 @@ + + + + + + + + + 2021-03-12T11:19:30.748666 + image/svg+xml + + + Matplotlib v3.3.2, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/index.rst b/docs/source/index.rst index c6489d15..be0a3674 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,12 +1,16 @@ - Documentation for ``ixdat`` ########################### The in-situ experimental data tool ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Welcome to the ``ixdat`` documentation. We hope that you can find what you are looking for here! -This documentation, like ``ixdat`` itself, is a work in progress and we welcome any feedback. +This documentation, like ``ixdat`` itself, is a work in progress and we appreciate any +feedback or requests `here `_. + +Note, we are currently compiling from the +`[user_ready] `_ +branch, not the master branch. .. toctree:: :maxdepth: 2 diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst index 3e6b6939..0b793111 100644 --- a/docs/source/introduction.rst +++ b/docs/source/introduction.rst @@ -2,7 +2,10 @@ Introduction ============ -For a long motivation, see :ref:`concept`. +.. image:: figures/ixdat_profile_pic.svg + :width: 200 + :alt: The power of combining techniques (fig made with ``EC_Xray``, an ``ixdat`` precursor) + ``ixdat`` will provide a powerful **object-oriented** interface to experimental data, especially in-situ experimental data for which it is of interest to combine data obtained simultaneously from multiple techniques. @@ -15,8 +18,29 @@ In addition to a **pluggable** parser interface for importing your data format, We will update this documentation as features are added. ``ixdat`` is free and open source software and we welcome input and new collaborators. -The source is here: https://github.com/ixdat +The source is here: https://github.com/ixdat/ixdat + +For a long motivation, see :ref:`concept`. + + +Installation +------------ + +To use ``ixdat``, you need to have python installed. We recommend Anaconda Prompt + +To install ``ixdat``, just type in your terminal or Anaconda prompt:: + + $ pip install ixdat + +And hit enter. ``ixdat`` is under development, and to make use of the newest features, +you may need to upgrade. This is also easy. Just type:: + + $ pip install --upgrade ixdat +Tutorials +---------- +``ixdat`` has a growing number of tutorials available as jupyter notebooks. The tutorials +are here: https://github.com/ixdat/tutorials .. toctree:: :maxdepth: 2 diff --git a/docs/source/measurement.rst b/docs/source/measurement.rst index fa1a986d..170f253a 100644 --- a/docs/source/measurement.rst +++ b/docs/source/measurement.rst @@ -8,7 +8,7 @@ main interface for user interaction. A measurement is an object of the generaliz main interface for user interaction. A measurement is an object of the generalized class ``Measurement``, defined in the ``measurements`` module, or an inheriting ***TechniqueMeasurement*** class defined in a module of the ``techniques`` folder -(see :ref:`techniques`_). +(see :ref:`techniques`). The general pluggable structure is defined by ``Measurement``, connecting every measurement to a *reader* for importing from text, a *backend* for saving and loading in @@ -32,7 +32,7 @@ EC, MS, and EC-MS as an example: :width: 400 :alt: Design: inheritance -A full list of TechniqueMeasurements is in :ref:`techniques`_. +A full list of TechniqueMeasurements is in :ref:`techniques`. Initiating a measurement ------------------------ @@ -44,7 +44,7 @@ by Biologic's EC-Lab, one can type: >>> from ixdat import Measurement >>> ec_meas = Measurement.read("my_file.mpt", reader="biologic") -See :ref:`readers`_ for a description of the available readers. +See :ref:`readers ` for a description of the available readers. The biologic reader (``ixdat.readers.biologic.BiologicMPTReader``) ensures that the object returned, ``ec_meas``, is of type ``ECMeasurement``. diff --git a/docs/source/plotter_docs/index.rst b/docs/source/plotter_docs/index.rst index 74c4035b..e64298e5 100644 --- a/docs/source/plotter_docs/index.rst +++ b/docs/source/plotter_docs/index.rst @@ -2,6 +2,7 @@ Plotters: visualizing `ixdat` data ================================== +Sourece: https://github.com/ixdat/ixdat/tree/user_ready/src/ixdat/plotters Basic ----- @@ -31,6 +32,7 @@ The ``ec_plotter`` module Mass Spectrometry ----------------- + .. _ms-plotter: The ``ms_plotter`` module diff --git a/docs/source/reader_docs/index.rst b/docs/source/reader_docs/index.rst index 2bb2ae79..aec89dda 100644 --- a/docs/source/reader_docs/index.rst +++ b/docs/source/reader_docs/index.rst @@ -1,7 +1,8 @@ .. _readers: -Readers: getting data into `ixdat` +Readers: getting data into ``ixdat`` ================================== +Source: https://github.com/ixdat/ixdat/tree/user_ready/src/ixdat/readers A full list of the readers thus accessible and their names can be viewed by typing: @@ -11,6 +12,10 @@ A full list of the readers thus accessible and their names can be viewed by typi Reading .csv files exported by ixdat: The ``IxdatCSVReader`` ------------------------------------------------------------ +``ixdat`` can export measureemnt data in a .csv format with necessary information in the +header. See :ref:`exporters`. It can naturally read the data that it exports itself. Exporting and reading, +however, may result in loss of raw data (unlike ``save()``). + The ``ixdat_csv`` module ........................ diff --git a/docs/source/technique_docs/ec_ms.rst b/docs/source/technique_docs/ec_ms.rst index 66dedf6c..251fd1e2 100644 --- a/docs/source/technique_docs/ec_ms.rst +++ b/docs/source/technique_docs/ec_ms.rst @@ -28,10 +28,14 @@ behind the EC-MS analysis and visualization in the puplications: The ``ec_ms`` module -------------------- +Source: https://github.com/ixdat/ixdat/tree/user_ready/src/ixdat/techniques/ec_ms.py + .. automodule:: ixdat.techniques.ec_ms :members: The ``deconvolution`` module ---------------------------- +Source: https://github.com/ixdat/ixdat/tree/user_ready/src/ixdat/techniques/deconvolution.py + .. automodule:: ixdat.techniques.deconvolution :members: \ No newline at end of file diff --git a/docs/source/technique_docs/electrochemistry.rst b/docs/source/technique_docs/electrochemistry.rst index 55dd1a4d..dd621171 100644 --- a/docs/source/technique_docs/electrochemistry.rst +++ b/docs/source/technique_docs/electrochemistry.rst @@ -3,32 +3,50 @@ Electrochemistry ================ -The main TechniqueMeasurement class for electrochemistry is the `ECMeasurement`. -Sublcasses of `ECMeasurement` include `CyclicVoltammagram` and `CyclicVoltammagramDiff`. +The main TechniqueMeasurement class for electrochemistry is the ``ECMeasurement``. +Sublcasses of ``ECMeasurement`` include ``CyclicVoltammagram`` and ``CyclicVoltammagramDiff``. -Direct-current electrochemsitry measurements (`ixdat` does not yet offer specific +Direct-current electrochemsitry measurements (``ixdat`` does not yet offer specific functionality for impedance data) are characterized by the essential quantities being working-electrode current (in loop with the counter electrode) and potential (vs the reference electrode) as a function of time. Either current or potential can be controlled as the input variable, so the other acts at the response, and it is common to plot current vs potential, but in all cases both are tracked or controlled as a function of -time. This results in the essential variables `t` (time), `v` (potential), and `j` -(current). The main job of `ECMeasurement` and subclasses is to give standardized, +time. This results in the essential variables ``t`` (time), ``v`` (potential), and ``j`` +(current). The main job of ``ECMeasurement`` and subclasses is to give standardized, convenient, and powerful access to these three variables for data selection, analysis, and visualization, regardless of which hardware the data was acquired with. -The default plotter, :ref:`ECPlotter `, plots these variables. +The default plotter, :ref:`ECPlotter `_, plots these variables. The default exporter, ECExporter, exports these variables as well as an incrementer for selecting data, ``cycle``. +Electrochemistry is the most thoroughly developed technique in ``ixdat``. For in-depth +examples of the functionality in the ``ECMeasurement`` class and its subclasses, see +the following Tutorials: + +- `Loading appending and saving `_ + +- `Analyzing cyclic voltammagrams `_ + The ``ec`` module ----------------- +Source: https://github.com/ixdat/ixdat/tree/user_ready/src/ixdat/techniques/ec.py + +.. image:: ../figures/ec_subplots.svg + :width: 600 + :alt: Example plots. left: ``ECMeasurement.plot_vs_potential()`` right: ``ECMeasurement.plot_measurement()`` .. automodule:: ixdat.techniques.ec :members: The ``cv`` module ----------------- +Source: https://github.com/ixdat/ixdat/tree/user_ready/src/ixdat/techniques/cv.py + +.. image:: ../figures/cv_diff.svg + :width: 300 + :alt: Example ``CyclicVoltammagramDiff`` plot .. automodule:: ixdat.techniques.cv :members: \ No newline at end of file diff --git a/docs/source/technique_docs/index.rst b/docs/source/technique_docs/index.rst index 990a509b..bcfddf1c 100644 --- a/docs/source/technique_docs/index.rst +++ b/docs/source/technique_docs/index.rst @@ -1,16 +1,23 @@ -.. _technqiues: +.. _techniques: Techniques: ``ixdat``'s measurement subclasses ============================================== +Source: https://github.com/ixdat/ixdat/tree/user_ready/src/ixdat/techniques TechniqueMeasurement classes (interchangeable with Techniques or Measurement subclasses) inherit from the ``Measurement`` class (see :ref:`measurement`) -A full list of the techniques and there names is in the ``TECHNIQUE_CLASSES`` dictionary: - ->>> from ixdat.techniques import TECHNIQUE_CLASSES ->>> TECHNIQUE_CLASSES +A full list of the techniques and there names is in the ``TECHNIQUE_CLASSES`` dictionary:: + >>> from ixdat.techniques import TECHNIQUE_CLASSES + >>> TECHNIQUE_CLASSES # note, more techniques may have been added since! + { + 'simple': , + 'EC': , + 'CV': , + 'MS': , + 'EC-MS': + } .. toctree:: :maxdepth: 2 diff --git a/docs/source/technique_docs/mass_spec.rst b/docs/source/technique_docs/mass_spec.rst index 323cd3cc..ace32a9d 100644 --- a/docs/source/technique_docs/mass_spec.rst +++ b/docs/source/technique_docs/mass_spec.rst @@ -2,15 +2,19 @@ Mass Spectrometry ================= +Source: https://github.com/ixdat/ixdat/tree/user_ready/src/ixdat/techniques/ms Mass spectrometry is commonly used in catalysis and electrocatalysis for two different types of data - spectra, where intensity is taken while scanning over m/z, and -mass intensity detectin (MID) where the intensity of a small set of m/z values are +mass intensity detection (MID) where the intensity of a small set of m/z values are tracked in time. -The main TechniqueMeasurement class for MID data is the `MSMeasurement`. +The main TechniqueMeasurement class for MID data is the :ref:`MSMeasurement`. Classes dealing with spectra are under development. +The ``ms`` module +........................ + .. automodule:: ixdat.techniques.ms :members: \ No newline at end of file From aae84716d1ee6e0a0e33073c03c060634c952a5a Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Sat, 13 Mar 2021 18:13:40 +0000 Subject: [PATCH 038/118] Write cinfdata text reader --- .../reader_testers/test_cinfdata_reader.py | 19 ++ docs/source/exporter_docs/index.rst | 4 +- docs/source/introduction.rst | 16 +- docs/source/reader_docs/index.rst | 17 ++ src/ixdat/readers/__init__.py | 2 + src/ixdat/readers/cinfdata.py | 208 ++++++++++++++++++ src/ixdat/readers/ixdat_csv.py | 13 +- 7 files changed, 265 insertions(+), 14 deletions(-) create mode 100644 development_scripts/reader_testers/test_cinfdata_reader.py create mode 100644 src/ixdat/readers/cinfdata.py diff --git a/development_scripts/reader_testers/test_cinfdata_reader.py b/development_scripts/reader_testers/test_cinfdata_reader.py new file mode 100644 index 00000000..e8bf5478 --- /dev/null +++ b/development_scripts/reader_testers/test_cinfdata_reader.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +""" +Created on Mon Jan 25 22:02:57 2021 + +@author: scott +""" +from pathlib import Path +from matplotlib import pyplot as plt + +from ixdat import Measurement + +path_to_file = ( + Path.home() + / "Dropbox/ixdat_resources/test_data/cinfdata/Trimarco2018_fig3/QMS_1.txt" +) + +meas = Measurement.read(path_to_file, reader="cinfdata") + +meas.plot_measurement() diff --git a/docs/source/exporter_docs/index.rst b/docs/source/exporter_docs/index.rst index 40b5ef10..2df82f23 100644 --- a/docs/source/exporter_docs/index.rst +++ b/docs/source/exporter_docs/index.rst @@ -1,7 +1,7 @@ .. _exporters: -Exporters: getting data out of `ixdat` -====================================== +Exporters: getting data out of ``ixdat`` +======================================== Here is an example of an ixdat csv file (exported from a ``CyclicVoltammagram`` diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst index 0b793111..67b4dd34 100644 --- a/docs/source/introduction.rst +++ b/docs/source/introduction.rst @@ -2,9 +2,10 @@ Introduction ============ -.. image:: figures/ixdat_profile_pic.svg - :width: 200 - :alt: The power of combining techniques (fig made with ``EC_Xray``, an ``ixdat`` precursor) +.. figure:: figures/ixdat_profile_pic.svg + :width: 200 + + The power of combining techniques (fig made with ``EC_Xray``, an ``ixdat`` precursor) ``ixdat`` will provide a powerful **object-oriented** interface to experimental data, especially in-situ experimental data for which it is of interest to combine data obtained simultaneously from multiple techniques. @@ -26,14 +27,17 @@ For a long motivation, see :ref:`concept`. Installation ------------ -To use ``ixdat``, you need to have python installed. We recommend Anaconda Prompt +To use ``ixdat``, you need to have python installed. We recommend +`Anaconda python `_. To install ``ixdat``, just type in your terminal or Anaconda prompt:: $ pip install ixdat -And hit enter. ``ixdat`` is under development, and to make use of the newest features, -you may need to upgrade. This is also easy. Just type:: +And hit enter. + +``ixdat`` is under development, and to make use of the newest features, +you may need to upgrade to the latest version. This is also easy. Just type:: $ pip install --upgrade ixdat diff --git a/docs/source/reader_docs/index.rst b/docs/source/reader_docs/index.rst index aec89dda..3b5a2f56 100644 --- a/docs/source/reader_docs/index.rst +++ b/docs/source/reader_docs/index.rst @@ -22,6 +22,23 @@ The ``ixdat_csv`` module .. automodule:: ixdat.readers.ixdat_csv :members: +Importing from other experimental data platforms +------------------------------------------------ + +**cinfdata** is a web-based database system for experimental data, developed and used at DTU SurfCat +(formerly CINF) in concert with The ``PyExpLabSys`` suite of experimental data acquisition tools. +Both are available at https://github.com/CINF. + +As of yet, ``ixdat`` only has a text-file reader for data exported from **cinfdata**, but +in the future it will also have a reader which downloads from the website given e.g. a +setup and date. + +The ``cinfdata`` module +........................ + +.. automodule:: ixdat.readers.cinfdata + :members: + Electrochemistry and sub-techniques ------------------------------------ diff --git a/src/ixdat/readers/__init__.py b/src/ixdat/readers/__init__.py index a01d27c7..b8dad071 100644 --- a/src/ixdat/readers/__init__.py +++ b/src/ixdat/readers/__init__.py @@ -17,6 +17,7 @@ # mass spectrometers from .pfeiffer import PVMassSpecReader +from .cinfdata import CinfdataTXTReader # ec-ms from .zilien import ZilienTSVReader @@ -28,6 +29,7 @@ "autolab": NovaASCIIReader, "ivium": IviumDatasetReader, "pfeiffer": PVMassSpecReader, + "cinfdata": CinfdataTXTReader, "zilien": ZilienTSVReader, "EC_MS": EC_MS_CONVERTER, } diff --git a/src/ixdat/readers/cinfdata.py b/src/ixdat/readers/cinfdata.py new file mode 100644 index 00000000..7bc2463d --- /dev/null +++ b/src/ixdat/readers/cinfdata.py @@ -0,0 +1,208 @@ +"""Module defining the ixdat csv reader, so ixdat can read the files it exports.""" + +from pathlib import Path +import numpy as np +from ..exceptions import ReadError +from ..data_series import ValueSeries, TimeSeries +from ..techniques import MSMeasurement +from .reading_tools import timestamp_string_to_tstamp + + +class CinfdataTXTReader: + """A class that reads the text exported by cinfdata's text export functionality + + TODO: We should also have a reader class that downloads the data from cinfdata like + `EC_MS`'s `download_cinfdata_set`: + https://github.com/ScottSoren/EC_MS/blob/master/src/EC_MS/Data_Importing.py#L711 + + Attributes: + path_to_file (Path): the location and name of the file read by the reader + n_line (int): the number of the last line read by the reader + place_in_file (str): The last location in the file read by the reader. This + is used internally to tell the reader how to parse each line. Options are: + "header", "column names", and "data". + header_lines (list of str): a list of the header lines of the files. This + includes the column name line. The header can be nicely viewed with the + print_header() function. + tstamp (str): The unix time corresponding to t=0 for the measurement + tstamp_list (list of float): list of epoch tstamps in the file's timestamp line + column_tstamps (dict): The unix time corresponding to t=0 for each time column + technique (str): The name of the technique + column_names (list of str): The names of the data columns in the file + t_and_v_cols (dict): {name: (tcol, vcol)} where name is the name of the + ValueSeries (e.g. "M2"), tcol is the name of the corresponding time column + in the file (e.g. "M2-x"), and vcol is the the name of the value column in + the file (e.g. "M2-y). + column_data (dict of str: np.array): The data in the file as a dict. + Note that the np arrays are the same ones as in the measurement's DataSeries, + so this does not waste memory. + file_has_been_read (bool): This is used to make sure read() is only successfully + called once by the Reader. False until read() is called, then True. + measurement (Measurement): The measurement returned by read() when the file is + read. self.measureemnt is None before read() is called. + """ + + delim = "\t" + + def __init__(self): + """Initialize a Reader for cinfdata-exported text files. See class docstring.""" + self.name = None + self.path_to_file = None + self.n_line = 0 + self.place_in_file = "header" + self.header_lines = [] + self.tstamp = None + self.tstamp_list = [] + self.column_tstamps = {} + self.column_names = [] + self.t_and_v_cols = {} + self.column_data = {} + self.technique = "MS" # TODO: Figure out how to tell if it's something else + self.measurement_class = MSMeasurement + self.file_has_been_read = False + self.measurement = None + + def read(self, path_to_file, name=None, cls=None, **kwargs): + """Return an MSMeasurement with the data and metadata recorded in path_to_file + + This loops through the lines of the file, processing one at a time. For header + lines, this involves searching for metadata. For the column name line, this + involves creating empty arrays for each data series. For the data lines, this + involves appending to these arrays. After going through all the lines, it + converts the arrays to DataSeries. + For cinfdata text files, each value column has its own timecolumn, and they are + not necessarily all the same length. + Finally, the method returns an ECMeasurement with these DataSeries. The + ECMeasurement contains a reference to the reader. + All attributes of this reader can be accessed from the + measurement as `measurement.reader.attribute_name`. + + Args: + path_to_file (Path): The full abs or rel path including the ".mpt" extension + **kwargs (dict): Key-word arguments are passed to ECMeasurement.__init__ + """ + path_to_file = Path(path_to_file) if path_to_file else self.path_to_file + if self.file_has_been_read: + print( + f"This {self.__class__.__name__} has already read {self.path_to_file}." + " Returning the measurement resulting from the original read. " + "Use a new Reader if you want to read another file." + ) + return self.measurement + self.name = name or path_to_file.name + self.path_to_file = path_to_file + with open(self.path_to_file, "r") as f: + for line in f: + self.process_line(line) + for name in self.column_names: + self.column_data[name] = np.array(self.column_data[name]) + + data_series_list = [] + for name, (tcol, vcol) in self.t_and_v_cols.items(): + tseries = TimeSeries( + name=tcol, + unit_name=get_column_unit(tcol) or "s", + data=self.column_data[tcol], + tstamp=self.column_tstamps[tcol], + ) + vseries = ValueSeries( + name=name, + data=self.column_data[vcol], + tseries=tseries, + unit_name=get_column_unit(vcol), + ) + data_series_list.append(tseries) + data_series_list.append(vseries) + + obj_as_dict = dict( + name=self.name, + technique=self.technique, + reader=self, + series_list=data_series_list, + tstamp=self.tstamp, + ) + # normally MSMeasurement requires mass aliases, but not cinfdata since it uses + # the ixdat convention (actually, ixdat uses the cinfdata convention) of M + obj_as_dict.update(kwargs) + + if issubclass(cls, self.measurement_class): + self.measurement_class = cls + + self.measurement = self.measurement_class.from_dict(obj_as_dict) + self.file_has_been_read = True + return self.measurement + + def process_line(self, line): + """Call the correct line processing method depending on self.place_in_file""" + if self.place_in_file == "header": + self.process_header_line(line) + elif self.place_in_file == "post_header": + if line.strip(): # then we're in the column headers! + self.process_column_line(line) + elif self.place_in_file == "data": + self.process_data_line(line) + else: # just for debugging + raise ReadError(f"place_in_file = {self.place_in_file}") + self.n_line += 1 + + def process_header_line(self, line): + """Search line for important metadata and set the relevant attribute of self""" + self.header_lines.append(line) + if not line.strip(): # the blank lines between the header and the column names + self.place_in_file = "post_header" + elif "Recorded at" in line: + for s in line.split(self.delim): + if not "Recorded at" in s: + self.tstamp_list.append( + timestamp_string_to_tstamp( + s.strip()[1:-1], # remove edge whitespace and quotes. + form="%Y-%m-%d %H:%M:%S", # like "2017-09-20 13:06:00" + ) + ) + self.tstamp = self.tstamp_list[0] + + def process_column_line(self, line): + """Split the line to get the names of the file's data columns""" + self.header_lines.append(line) + self.column_names = [name.strip() for name in line.split(self.delim)] + self.column_data.update({name: [] for name in self.column_names}) + i = 0 # need a counter to map tstamps to timecols. + for col in self.column_names: + if col.endswith("-y"): + name = col[:-2] + tcol = f"{name}-x" + if not tcol in self.column_names: + print(f"Warning! No timecol for {col}. Expected {tcol}. Ignoring.") + continue + self.t_and_v_cols[name] = (tcol, col) + self.column_tstamps[tcol] = self.tstamp_list[i] + i += 1 + + self.place_in_file = "data" + + def process_data_line(self, line): + """Split the line and append the numbers the corresponding data column arrays""" + data_strings_from_line = line.strip().split(self.delim) + for name, value_string in zip(self.column_names, data_strings_from_line): + if value_string: + try: + value = float(value_string) + except ValueError: + raise ReadError(f"can't parse value string '{value_string}'") + self.column_data[name].append(value) + + def print_header(self): + """Print the file header including column names. read() must be called first.""" + header = "".join(self.header_lines) + print(header) + + +def get_column_unit(column_name): + """Return the unit name of an ixdat column, i.e the part of the name after the '/'""" + if column_name.startswith("M") and column_name.endswith("-y"): + unit_name = "A" + elif column_name.startswith("M") and column_name.endswith("-x"): + unit_name = "s" + else: # TODO: Figure out how cinfdata represents units for other stuff. + unit_name = None + return unit_name diff --git a/src/ixdat/readers/ixdat_csv.py b/src/ixdat/readers/ixdat_csv.py index 3be8fe63..ce9e8224 100644 --- a/src/ixdat/readers/ixdat_csv.py +++ b/src/ixdat/readers/ixdat_csv.py @@ -53,7 +53,7 @@ class IxdatCSVReader: delim = "," def __init__(self): - """Initialize a Reader for .mpt files. See class docstring.""" + """Initialize a Reader for ixdat-exported .csv files. See class docstring.""" self.name = None self.path_to_file = None self.n_line = 0 @@ -70,17 +70,18 @@ def __init__(self): self.measurement = None def read(self, path_to_file, name=None, cls=None, **kwargs): - """Return an ECMeasurement with the data and metadata recorded in path_to_file + """Return a Measurement with the data and metadata recorded in path_to_file This loops through the lines of the file, processing one at a time. For header lines, this involves searching for metadata. For the column name line, this involves creating empty arrays for each data series. For the data lines, this involves appending to these arrays. After going through all the lines, it converts the arrays to DataSeries. - For .mpt files, there is one TimeSeries, with name "time/s", and all other data - series are ValueSeries sharing this TimeSeries. - Finally, the method returns an ECMeasurement with these DataSeries. The - ECMeasurement contains a reference to the reader. + The technique is specified in the header, and used to pick the + TechniqueMeasurement class. + Finally, the method returns a TechniqueMeasurement object `measurement` + with these DataSeries. All attributes of this reader can be accessed from the + measurement as `measurement.reader.attribute_name`. Args: path_to_file (Path): The full abs or rel path including the ".mpt" extension From 6cf1caaeccd1230a2c0e0c2aafa633bea21ee6b2 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Sat, 13 Mar 2021 23:40:27 +0000 Subject: [PATCH 039/118] Hyphenating with '+' operator! two-axis EC-MS plot! --- .../reader_testers/test_cinfdata_reader.py | 23 +- docs/source/figures/ec_ms_annotated.svg | 2047 +++++++++++++++++ docs/source/plotter_docs/index.rst | 8 +- docs/source/technique_docs/ec_ms.rst | 15 +- .../technique_docs/electrochemistry.rst | 10 +- src/ixdat/measurements.py | 72 +- src/ixdat/plotters/ecms_plotter.py | 46 +- src/ixdat/plotters/ms_plotter.py | 63 +- src/ixdat/techniques/ec_ms.py | 6 +- 9 files changed, 2262 insertions(+), 28 deletions(-) create mode 100644 docs/source/figures/ec_ms_annotated.svg diff --git a/development_scripts/reader_testers/test_cinfdata_reader.py b/development_scripts/reader_testers/test_cinfdata_reader.py index e8bf5478..c01e9b50 100644 --- a/development_scripts/reader_testers/test_cinfdata_reader.py +++ b/development_scripts/reader_testers/test_cinfdata_reader.py @@ -13,7 +13,26 @@ Path.home() / "Dropbox/ixdat_resources/test_data/cinfdata/Trimarco2018_fig3/QMS_1.txt" ) +ms_meas = Measurement.read(path_to_file, reader="cinfdata") +ms_meas.plot_measurement() -meas = Measurement.read(path_to_file, reader="cinfdata") +path_to_ec_file_start = ( + Path.home() / "Dropbox/ixdat_resources/test_data/cinfdata/Trimarco2018_fig3/09_fig4" +) +ec_meas = Measurement.read_set(path_to_ec_file_start, reader="biologic") +ec_meas.calibrate(RE_vs_RHE=0.65, A_el=0.196) +ec_meas.plot_measurement() + +ecms_meas = ec_meas + ms_meas +axes = ecms_meas.plot_measurement( + mass_lists=[["M44", "M2"], ["M4", "M28"]], + tspan_bg=[30, 40], + legend=False, + unit="pA", +) -meas.plot_measurement() +axes[0].set_ylim([-7, 70]) +axes[-1].set_ylim([-1.8e3, 18e3]) +fig = axes[0].get_figure() +fig.tight_layout() +# fig.savefig("../../docs/source/figures/ec_ms.svg") diff --git a/docs/source/figures/ec_ms_annotated.svg b/docs/source/figures/ec_ms_annotated.svg new file mode 100644 index 00000000..a4e2b105 --- /dev/null +++ b/docs/source/figures/ec_ms_annotated.svg @@ -0,0 +1,2047 @@ + + + + + + + + 2021-03-13T22:48:52.540275 + image/svg+xml + + + Matplotlib v3.3.2, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + m/z=2 (H2) + m/z=4 +(He) + m/z=28 (CO) + m/z=44 +(CO2) + U + J + + + + + + + diff --git a/docs/source/plotter_docs/index.rst b/docs/source/plotter_docs/index.rst index e64298e5..64964063 100644 --- a/docs/source/plotter_docs/index.rst +++ b/docs/source/plotter_docs/index.rst @@ -1,7 +1,7 @@ .. _plotters: -Plotters: visualizing `ixdat` data -================================== +Plotters: visualizing ``ixdat`` data +==================================== Sourece: https://github.com/ixdat/ixdat/tree/user_ready/src/ixdat/plotters Basic @@ -22,7 +22,7 @@ The ``value_plotter`` module Electrochemistry ---------------- -.. _ec-plotter: +.. _`ec-plotter`: The ``ec_plotter`` module ............................... @@ -33,7 +33,7 @@ The ``ec_plotter`` module Mass Spectrometry ----------------- -.. _ms-plotter: +.. _`ms-plotter`: The ``ms_plotter`` module ............................... diff --git a/docs/source/technique_docs/ec_ms.rst b/docs/source/technique_docs/ec_ms.rst index 251fd1e2..b696fd09 100644 --- a/docs/source/technique_docs/ec_ms.rst +++ b/docs/source/technique_docs/ec_ms.rst @@ -5,7 +5,20 @@ Electrochemistry - Mass Spectrometry (EC-MS) The main class for EC-MS data is the ECMSMeasurement. -It comes with the EC-MS plotter which makes +It comes with the :ref:`EC-MS plotter ` which makes EC-MS plots like this one: + +.. figure:: ../figures/ec_ms_annotated.svg + :width: 600 + + ``ECMSMeasurement.plot_measurement()``. Data from Trimarco, 2018. + +Other than that it doesn't have much but inherits from both ``ECMeasurement`` and ``MSMeasurement``. +An ``ECMSMeasurement`` can be created either by adding an ``ECMeasurement`` and an ``MSMeasurement`` +using the ``+`` operator, or by directly importing data using an EC-MS :ref:`reader ` +such as "zilien". + +Deconvolution, described in a publication under review, is implemented in the deconvolution module, +in a class inheriting from ``ECMSMeasurement``. ixdat will soon have all the functionality and more for EC-MS data and analysis as the legacy `EC_MS `_ package. This includes the tools diff --git a/docs/source/technique_docs/electrochemistry.rst b/docs/source/technique_docs/electrochemistry.rst index dd621171..1792a2b6 100644 --- a/docs/source/technique_docs/electrochemistry.rst +++ b/docs/source/technique_docs/electrochemistry.rst @@ -17,7 +17,7 @@ time. This results in the essential variables ``t`` (time), ``v`` (potential), a convenient, and powerful access to these three variables for data selection, analysis, and visualization, regardless of which hardware the data was acquired with. -The default plotter, :ref:`ECPlotter `_, plots these variables. +The default plotter, :ref:`ECPlotter `, plots these variables. The default exporter, ECExporter, exports these variables as well as an incrementer for selecting data, ``cycle``. @@ -33,10 +33,12 @@ The ``ec`` module ----------------- Source: https://github.com/ixdat/ixdat/tree/user_ready/src/ixdat/techniques/ec.py -.. image:: ../figures/ec_subplots.svg +.. figure:: ../figures/ec_subplots.svg :width: 600 :alt: Example plots. left: ``ECMeasurement.plot_vs_potential()`` right: ``ECMeasurement.plot_measurement()`` + left: ``ECMeasurement.plot_vs_potential()`` right: ``ECMeasurement.plot_measurement()``. `Tutorial `_ + .. automodule:: ixdat.techniques.ec :members: @@ -44,9 +46,11 @@ The ``cv`` module ----------------- Source: https://github.com/ixdat/ixdat/tree/user_ready/src/ixdat/techniques/cv.py -.. image:: ../figures/cv_diff.svg +.. figure:: ../figures/cv_diff.svg :width: 300 :alt: Example ``CyclicVoltammagramDiff`` plot + ``CyclicVoltammagramDiff.plot()``. `Tutorial `_. + .. automodule:: ixdat.techniques.cv :members: \ No newline at end of file diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index 6c06dd0a..22efb154 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -5,6 +5,7 @@ to visualize and analyze the combined dataset. Dataset is also the base class for a number of technique-specific Dataset-derived classes. """ +from pathlib import Path import json import numpy as np from .db import Saveable, PlaceHolderObject @@ -147,7 +148,13 @@ def from_dict(cls, obj_as_dict): @classmethod def read(cls, path_to_file, reader, **kwargs): - """Return a Measurement object from parsing a file with the specified reader""" + """Return a Measurement object from parsing a file with the specified reader + + Args: + path_to_file (Path or str): The path to the file to read + reader (str or Reader class): The (name of the) reader to read the file with. + kwargs: key-word arguments are passed on to the reader's read() method. + """ if isinstance(reader, str): # TODO: see if there isn't a way to put the import at the top of the module. # see: https://github.com/ixdat/ixdat/pull/1#discussion_r546437471 @@ -167,6 +174,37 @@ def read_url(cls, url, reader, **kwargs): path_to_temp_file.unlink() return measurement + @classmethod + def read_set(cls, path_to_file_start, reader, file_list=None, **kwargs): + """Read and append a set of files. + + Args: + path_to_file_start (Path or str): The path to the files to read including + the shared start of the file name: `Path(path_to_file).parent` is + interpreted as the folder where the file are. + `Path(path_to_file).name` is interpreted as the shared start of the files + to be appended. + reader (str or Reader class): The (name of the) reader to read the files with + file_list (list of Path): As an alternative to path_to_file_start, the + exact files to append can be specified in a list + kwargs: Key-word arguments are passed via cls.read() to the reader's read() + method, AND to cls.from_component_measurements() + """ + base_name = None + if not file_list: + folder = Path(path_to_file_start).parent + base_name = Path(path_to_file_start).name + file_list = [f for f in folder.iterdir() if f.name.startswith(base_name)] + + component_measurements = [ + cls.read(f, reader=reader, **kwargs) for f in file_list + ] + + if base_name and "name" not in kwargs: + kwargs["name"] = base_name + measurement = cls.from_component_measurements(component_measurements, **kwargs) + return measurement + @classmethod def from_component_measurements( cls, component_measurements, keep_originals=True, **kwargs @@ -643,6 +681,16 @@ def select(self, *args, tspan=None, **kwargs): new_measurement = new_measurement.select_values(*args, **kwargs) return new_measurement + def tspan(self): + """Return (t_start, t_finish) for all data in the measurement""" + t_start = None + t_finish = None + for tcol in self.time_names: + t = self[tcol].data + t_start = min(t_start, t[0]) if t_start else t[0] + t_finish = max(t_finish, t[-1]) if t_finish else t[-1] + return t_start, t_finish + def __add__(self, other): """Addition of measurements appends the series and component measurements lists. @@ -665,7 +713,7 @@ def __add__(self, other): """ obj_as_dict = self.as_dict() new_name = self.name + " AND " + other.name - new_technique = self.technique + " AND " + other.technique + new_technique = get_combined_technique(self.technique, other.technique) # TODO: see if there isn't a way to put the import at the top of the module. # see: https://github.com/ixdat/ixdat/pull/1#discussion_r546437410 @@ -684,6 +732,7 @@ def __add__(self, other): ) obj_as_dict.update( name=new_name, + technique=new_technique, series_list=new_series_list, component_measurements=new_component_measurements, ) @@ -821,3 +870,22 @@ def time_shifted(series, tstamp=None): tseries=time_shifted(series.tseries, tstamp=tstamp), ) return series + + +def get_combined_technique(technique_1, technique_2): + """Return the name of the technique resulting from adding two techniques""" + # TODO: see if there isn't a way to put the import at the top of the module. + # see: https://github.com/ixdat/ixdat/pull/1#discussion_r546437410 + if technique_1 == technique_2: + return technique_1 + + from .techniques import TECHNIQUE_CLASSES + + for hyphenated in [ + technique_1 + "-" + technique_2, + technique_2 + "-" + technique_1, + ]: + if hyphenated in TECHNIQUE_CLASSES: + return hyphenated + + return technique_1 + " AND " + technique_2 diff --git a/src/ixdat/plotters/ecms_plotter.py b/src/ixdat/plotters/ecms_plotter.py index eabfbd8f..8bd61cd9 100644 --- a/src/ixdat/plotters/ecms_plotter.py +++ b/src/ixdat/plotters/ecms_plotter.py @@ -17,12 +17,15 @@ def plot_measurement( measurement=None, axes=None, mass_list=None, + mass_lists=None, tspan=None, + tspan_bg=None, + unit="A", V_str=None, J_str=None, V_color="k", J_color="r", - logplot=True, + logplot=None, legend=True, **kwargs, ): @@ -34,7 +37,6 @@ def plot_measurement( - variable subplot sizing (emphasizing EC or MS) - plotting of calibrated data (mol_list instead of mass_list) - units! - - optionally two y-axes in the upper panel Args: measurement (ECMSMeasurement): defaults to the measurement to which the plotter is bound (self.measurement) @@ -45,29 +47,48 @@ def plot_measurement( the left and right y-axes of the lower panel with 2/5 the area. mass_list (list of str): The names of the m/z values, eg. ["M2", ...] to plot. Defaults to all of them (measurement.mass_list) + mass_lists (list of list of str): Alternately, two lists can be given for + masses in which case one list is plotted on the left y-axis and the other + on the right y-axis of the top panel. tspan (iter of float): The time interval to plot, wrt measurement.tstamp + tspan_bg (timespan): A timespan for which to assume the signal is at its + background. The average signals during this timespan are subtracted. + If `mass_lists` are given rather than a single `mass_list`, `tspan_bg` + must also be two timespans - one for each axis. Default is `None` for no + background subtraction. + unit (str): the unit for the MS data. Defaults to "A" for Ampere V_str (str): The name of the value to plot on the lower left y-axis. Defaults to the name of the series `measurement.potential` J_str (str): The name of the value to plot on the lower right y-axis. Defaults to the name of the series `measurement.current` V_color (str): The color to plot the variable given by 'V_str' J_color (str): The color to plot the variable given by 'J_str' - logplot (bool): Whether to plot the MS data on a log scale (default True) + logplot (bool): Whether to plot the MS data on a log scale (default True + unless mass_lists are given) legend (bool): Whether to use a legend for the MS data (default True) kwargs (dict): Additional kwargs go to all calls of matplotlib's plot() """ measurement = measurement or self.measurement + logplot = (not mass_lists) if logplot is None else logplot + if not axes: gs = gridspec.GridSpec(5, 1) # gs.update(hspace=0.025) axes = [plt.subplot(gs[0:3, 0])] axes += [plt.subplot(gs[3:5, 0])] axes += [axes[1].twinx()] + + if not tspan: + if hasattr(measurement, "potential") and measurement.potential: + t, _ = measurement.grab("potential") + tspan = [t[0], t[-1]] + else: + tspan = measurement.tspan + if hasattr(measurement, "potential") and measurement.potential: # then we have EC data! - ECPlotter.plot_measurement( - self, + ECPlotter().plot_measurement( measurement=measurement, axes=[axes[1], axes[2]], tspan=tspan, @@ -79,14 +100,24 @@ def plot_measurement( ) if mass_list or hasattr(measurement, "mass_list"): # then we have MS data! - MSPlotter.plot_measurement( - self, + ms_axes = MSPlotter().plot_measurement( + measurement=measurement, ax=axes[0], tspan=tspan, + tspan_bg=tspan_bg, mass_list=mass_list, + mass_lists=mass_lists, + unit=unit, logplot=logplot, legend=legend, ) + try: + ms_axes.set_ylabel(f"signal / [{unit}]") + except AttributeError: + for ax in ms_axes: + ax.set_ylabel(f"signal / [{unit}]") + if ax not in axes: + axes.append(ax) axes[0].xaxis.set_label_position("top") axes[0].tick_params( axis="x", top=True, bottom=False, labeltop=True, labelbottom=False @@ -96,4 +127,3 @@ def plot_measurement( def plot_vs_potential(self): """FIXME: This is needed due to assignment in ECMeasurement.__init__""" - pass diff --git a/src/ixdat/plotters/ms_plotter.py b/src/ixdat/plotters/ms_plotter.py index fe3a7653..f6d1c544 100644 --- a/src/ixdat/plotters/ms_plotter.py +++ b/src/ixdat/plotters/ms_plotter.py @@ -1,3 +1,4 @@ +import numpy as np from .base_mpl_plotter import MPLPlotter @@ -13,7 +14,10 @@ def plot_measurement( measurement=None, ax=None, mass_list=None, + mass_lists=None, tspan=None, + tspan_bg=None, + unit="A", logplot=True, legend=True, ): @@ -24,23 +28,72 @@ def plot_measurement( ax (matplotlib axis): Defaults to a new axis mass_list (list of str): The names of the m/z values, eg. ["M2", ...] to plot. Defaults to all of them (measurement.mass_list) - tspan (iter of float): The timespan, wrt measurement.tstamp, on which to plot + mass_lists (list of list of str): Alternately, two lists can be given for + masses in which case one list is plotted on the left y-axis and the other + on the right y-axis of the top panel. + tspan (iter of float): The time interval to plot, wrt measurement.tstamp + tspan_bg (timespan): A timespan for which to assume the signal is at its + background. The average signals during this timespan are subtracted. + If `mass_lists` are given rather than a single `mass_list`, `tspan_bg` + must also be two timespans - one for each axis. Default is `None` for no + background subtraction. logplot (bool): Whether to plot the MS data on a log scale (default True) legend (bool): Whether to use a legend for the MS data (default True) """ - if not ax: - ax = self.new_ax(ylabel="signal / [A]", xlabel="time / [s]") measurement = measurement or self.measurement + if not ax: + ax = self.new_ax(ylabel=f"signal / [{unit}]", xlabel="time / [s]") + tspan_bg_right = None + if mass_lists: + mass_list = mass_lists[0] + try: + tspan_bg_right = tspan_bg[1] + if isinstance(tspan_bg_right, (float, int)): + raise TypeError + except (KeyError, TypeError): + tspan_bg_right = None + else: + tspan_bg = tspan_bg[0] + unit_factor = {"pA": 1e12, "nA": 1e9, "uA": 1e6, "A": 1}[unit] + # TODO: Real units with a unit module! This should even be able to figure out the + # unit prefix to put stuff in a nice 1-to-1e3 range + if not ax: + ax = self.new_ax(ylabel=f"signal / [{unit}]", xlabel="time / [s]") + mass_list = mass_list or measurement.mass_list for mass in mass_list: t, v = measurement.grab(mass, tspan=tspan, include_endpoints=False) - v[v < MIN_SIGNAL] = MIN_SIGNAL - ax.plot(t, v, color=STANDARD_COLORS.get(mass, "k"), label=mass) + if logplot: + v[v < MIN_SIGNAL] = MIN_SIGNAL + if tspan_bg: + _, v_bg = measurement.grab(mass, tspan=tspan_bg) + v = v - np.mean(v_bg) + ax.plot( + t, v * unit_factor, color=STANDARD_COLORS.get(mass, "k"), label=mass + ) + if mass_lists: + ax2 = ax.twinx() + self.plot_measurement( + measurement=measurement, + ax=ax2, + mass_list=mass_lists[1], + unit=unit, + tspan=tspan, + tspan_bg=tspan_bg_right, + logplot=logplot, + legend=legend, + ) + axes = [ax, ax2] + else: + axes = None + if logplot: ax.set_yscale("log") if legend: ax.legend() + return axes if axes else ax + MIN_SIGNAL = 1e-14 # So that the bottom half of the plot isn't wasted on log(noise) diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index b63372a9..aa7b4db8 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -4,14 +4,14 @@ class ECMSMeasurement(ECMeasurement, MSMeasurement): - """Class implementing raw EC-MS functionality""" + """Class for raw EC-MS functionality. Parents: ECMeasurement and MSMeasurement""" def __init__(self, name, **kwargs): - super().__init__(name, **kwargs) + super().__init__(name, **kwargs) # FIXME: This seems to just be ECMeasurement. @property def plotter(self): - """The default plotter for ECMeasurement is ECPlotter""" + """The default plotter for ECMSMeasurement is ECMSPlotter""" if not self._plotter: from ..plotters.ecms_plotter import ECMSPlotter From f86bd0001173e05428d200c5d788eae774fdc565 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Wed, 17 Mar 2021 20:59:48 +0000 Subject: [PATCH 040/118] debug measurement appending+hyphenating, improve plotters --- src/ixdat/__init__.py | 2 +- src/ixdat/measurements.py | 81 ++++++++++++++++++++------ src/ixdat/plotters/base_mpl_plotter.py | 37 ++++++++++++ src/ixdat/plotters/ecms_plotter.py | 22 ++++--- src/ixdat/plotters/ms_plotter.py | 19 +++--- src/ixdat/techniques/ec.py | 6 +- 6 files changed, 129 insertions(+), 38 deletions(-) diff --git a/src/ixdat/__init__.py b/src/ixdat/__init__.py index 6d900eac..7c242742 100644 --- a/src/ixdat/__init__.py +++ b/src/ixdat/__init__.py @@ -1,6 +1,6 @@ """initialize ixdat, giving top-level access to a few of the important structures """ -__version__ = "0.0.8" +__version__ = "0.0.9" __title__ = "ixdat" __description__ = "The in-situ experimental data tool" __url__ = "https://github.com/ixdat/ixdat" diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index 22efb154..e961f774 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -175,7 +175,9 @@ def read_url(cls, url, reader, **kwargs): return measurement @classmethod - def read_set(cls, path_to_file_start, reader, file_list=None, **kwargs): + def read_set( + cls, path_to_file_start, reader, suffix=None, file_list=None, **kwargs + ): """Read and append a set of files. Args: @@ -187,6 +189,8 @@ def read_set(cls, path_to_file_start, reader, file_list=None, **kwargs): reader (str or Reader class): The (name of the) reader to read the files with file_list (list of Path): As an alternative to path_to_file_start, the exact files to append can be specified in a list + suffix (str): If a suffix is given, only files with the specified ending are + added to the file list kwargs: Key-word arguments are passed via cls.read() to the reader's read() method, AND to cls.from_component_measurements() """ @@ -195,6 +199,8 @@ def read_set(cls, path_to_file_start, reader, file_list=None, **kwargs): folder = Path(path_to_file_start).parent base_name = Path(path_to_file_start).name file_list = [f for f in folder.iterdir() if f.name.startswith(base_name)] + if suffix: + file_list = [f for f in file_list if f.suffix == suffix] component_measurements = [ cls.read(f, reader=reader, **kwargs) for f in file_list @@ -207,7 +213,7 @@ def read_set(cls, path_to_file_start, reader, file_list=None, **kwargs): @classmethod def from_component_measurements( - cls, component_measurements, keep_originals=True, **kwargs + cls, component_measurements, keep_originals=True, sort=True, **kwargs ): """Return a measurement with the data contained in the component measurements @@ -219,6 +225,7 @@ def from_component_measurements( component_measurements (list of Measurement) keep_originals: Whether to keep a list of component_measurements referenced. This may result in redundant numpy arrays in RAM. + sort (bool): Whether to sort the series according to time kwargs: key-word arguments are added to the dictionary for cls.from_dict() Returns cls: a Measurement object of the @@ -254,8 +261,12 @@ def from_component_measurements( # Now we make DataSeries, starting with all the TimeSeries tseries_dict = {} + sort_indeces = {} for name, s_as_dict in series_as_dicts.items(): if "tstamp" in s_as_dict: + if sort: + sort_indeces[name] = np.argsort(s_as_dict["data"]) + s_as_dict["data"] = s_as_dict["data"][sort_indeces[name]] tseries_dict[name] = TimeSeries.from_dict(s_as_dict) # And then ValueSeries, and put both in with the TimeSeries series_list = [] @@ -264,12 +275,33 @@ def from_component_measurements( series_list.append(tseries_dict[name]) elif "t_name" in s_as_dict: tseries = tseries_dict[s_as_dict["t_name"]] - vseries = ValueSeries( - name=name, - data=s_as_dict["data"], - unit_name=s_as_dict["unit_name"], - tseries=tseries, - ) + if s_as_dict["data"].shape == tseries.shape: + # Then we assume that the time and value data have lined up + # successfully! :D + if sort: + s_as_dict["data"] = s_as_dict["data"][ + sort_indeces[tseries.name] + ] + vseries = ValueSeries( + name=name, + data=s_as_dict["data"], + unit_name=s_as_dict["unit_name"], + tseries=tseries, + ) + else: + # this will be the case if vseries sharing the same tseries + # are not present in the same subset of component_measurements. + # In that case just append the vseries even though some tdata gets + # duplicated. + vseries = append_series( + [ + s + for m in component_measurements + for s in m.series_list + if s.name == name + ], + sort=sort, + ) series_list.append(vseries) # Finally, add this series to the dictionary representation and return the object @@ -681,6 +713,7 @@ def select(self, *args, tspan=None, **kwargs): new_measurement = new_measurement.select_values(*args, **kwargs) return new_measurement + @property def tspan(self): """Return (t_start, t_finish) for all data in the measurement""" t_start = None @@ -746,30 +779,30 @@ def __add__(self, other): # awkwardness there. -def append_series(series_list, sorted=True, tstamp=None): +def append_series(series_list, sort=True, tstamp=None): """Return series appending series_list relative to series_list[0].tseries.tstamp Args: series_list (list of Series): The series to append (must all be of same type) - sorted (bool): Whether to sort the data so that time only goes forward + sort (bool): Whether to sort the data so that time only goes forward tstamp (unix tstamp): The t=0 of the returned series or its TimeSeries. """ s0 = series_list[0] if isinstance(s0, TimeSeries): - return append_tseries(series_list, sorted=sorted, tstamp=tstamp) + return append_tseries(series_list, sort=sort, tstamp=tstamp) elif isinstance(s0, ValueSeries): - return append_vseries_by_time(series_list, sorted=sorted, tstamp=tstamp) + return append_vseries_by_time(series_list, sort=sort, tstamp=tstamp) raise BuildError( f"An algorithm of append_series for series like {s0} is not yet implemented" ) -def append_vseries_by_time(series_list, sorted=True, tstamp=None): +def append_vseries_by_time(series_list, sort=True, tstamp=None): """Return new ValueSeries with the data in series_list appended Args: series_list (list of ValueSeries): The value series to append - sorted (bool): Whether to sort the data so that time only goes forward + sort (bool): Whether to sort the data so that time only goes forward tstamp (unix tstamp): The t=0 of the returned ValueSeries' TimeSeries. """ name = series_list[0].name @@ -778,25 +811,25 @@ def append_vseries_by_time(series_list, sorted=True, tstamp=None): data = np.array([]) tseries_list = [s.tseries for s in series_list] tseries, sort_indeces = append_tseries( - tseries_list, sorted=sorted, return_sort_indeces=True, tstamp=tstamp + tseries_list, sort=sort, return_sort_indeces=True, tstamp=tstamp ) for s in series_list: if not (s.unit == unit and s.__class__ == cls): raise BuildError(f"can't append {series_list}") data = np.append(data, s.data) - if sorted: + if sort: data = data[sort_indeces] return cls(name=name, unit_name=unit.name, data=data, tseries=tseries) -def append_tseries(series_list, sorted=True, return_sort_indeces=False, tstamp=None): +def append_tseries(series_list, sort=True, return_sort_indeces=False, tstamp=None): """Return new TimeSeries with the data appended. Args: series_list (list of TimeSeries): The time series to append - sorted (bool): Whether to sort the data so that time only goes forward + sort (bool): Whether to sort the data so that time only goes forward return_sort_indeces (bool): Whether to return the indeces that sort the data tstamp (unix tstamp): The t=0 of the returned TimeSeries. """ @@ -811,7 +844,7 @@ def append_tseries(series_list, sorted=True, return_sort_indeces=False, tstamp=N raise BuildError(f"can't append {series_list}") data = np.append(data, s.data + s.tstamp - tstamp) - if sorted: + if sort: sort_indices = np.argsort(data) data = data[sort_indices] else: @@ -879,6 +912,15 @@ def get_combined_technique(technique_1, technique_2): if technique_1 == technique_2: return technique_1 + # if we're a component technique of a hyphenated technique to that hyphenated + # technique, the result is still the hyphenated technique. e.g. EC-MS + MS = EC-MS + if "-" in technique_1 and technique_2 in technique_1.split("-"): + return technique_1 + elif "-" in technique_2 and technique_1 in technique_2.split("-"): + return technique_2 + + # if we're adding two independent technique which are components of a hyphenated + # technique, then we want that hyphenated technique. e.g. EC + MS = EC-MS from .techniques import TECHNIQUE_CLASSES for hyphenated in [ @@ -888,4 +930,5 @@ def get_combined_technique(technique_1, technique_2): if hyphenated in TECHNIQUE_CLASSES: return hyphenated + # if all else fails, we just join them with " AND ". e.g. MS + XRD = MS AND XRD return technique_1 + " AND " + technique_2 diff --git a/src/ixdat/plotters/base_mpl_plotter.py b/src/ixdat/plotters/base_mpl_plotter.py index 1e7ff8cb..e8e29bc0 100644 --- a/src/ixdat/plotters/base_mpl_plotter.py +++ b/src/ixdat/plotters/base_mpl_plotter.py @@ -1,11 +1,48 @@ from matplotlib import pyplot as plt +from matplotlib import gridspec class MPLPlotter: def new_ax(self, xlabel=None, ylabel=None): + """Return a new matplotlib axis optionally with the given x and y labels""" fig, ax = plt.subplots() if xlabel: ax.set_xlabel(xlabel) if ylabel: ax.set_ylabel(ylabel) return ax + + def new_two_panel_axes(self, n_bottom=1, n_top=1, emphasis="top"): + """Return the axes handles for a bottom and top panel. + + Args: + n_top (int): 1 for a single y-axis, 2 for left and right y-axes on top panel + n_bottom (int): 1 for a single y-axis, 2 for left and right y-axes on bottom + emphasis (str or None): "top" for bigger top panel, "bottom" for bigger + bottom panel, None for equal-sized panels + + Returns list of axes: top left, bottom left(, bottom right)(, top right) + """ + self.new_ax() # necessary to avoid deleting an open figure, I don't know why + if emphasis == "top": + gs = gridspec.GridSpec(5, 1) + # gs.update(hspace=0.025) + axes = [plt.subplot(gs[0:3, 0])] + axes += [plt.subplot(gs[3:5, 0])] + elif emphasis == "bottom": + gs = gridspec.GridSpec(5, 1) + # gs.update(hspace=0.025) + axes = [plt.subplot(gs[0:2, 0])] + axes += [plt.subplot(gs[2:5, 0])] + else: + gs = gridspec.GridSpec(6, 1) + # gs.update(hspace=0.025) + axes = [plt.subplot(gs[0:3, 0])] + axes += [plt.subplot(gs[3:6, 0])] + + if n_bottom == 2: + axes += [axes[1].twinx()] + if n_top == 2: + axes += [axes[0].twinx()] + + return axes diff --git a/src/ixdat/plotters/ecms_plotter.py b/src/ixdat/plotters/ecms_plotter.py index 8bd61cd9..bc2f1699 100644 --- a/src/ixdat/plotters/ecms_plotter.py +++ b/src/ixdat/plotters/ecms_plotter.py @@ -1,10 +1,9 @@ -from matplotlib import pyplot as plt -from matplotlib import gridspec +from .base_mpl_plotter import MPLPlotter from .ec_plotter import ECPlotter from .ms_plotter import MSPlotter -class ECMSPlotter: +class ECMSPlotter(MPLPlotter): """A matplotlib plotter for EC-MS measurements.""" def __init__(self, measurement=None): @@ -27,6 +26,7 @@ def plot_measurement( J_color="r", logplot=None, legend=True, + emphasis="top", **kwargs, ): """Make an EC-MS plot vs time and return the axis handles. @@ -66,6 +66,8 @@ def plot_measurement( logplot (bool): Whether to plot the MS data on a log scale (default True unless mass_lists are given) legend (bool): Whether to use a legend for the MS data (default True) + emphasis (str or None): "top" for bigger top panel, "bottom" for bigger + bottom panel, None for equal-sized panels kwargs (dict): Additional kwargs go to all calls of matplotlib's plot() """ measurement = measurement or self.measurement @@ -73,11 +75,9 @@ def plot_measurement( logplot = (not mass_lists) if logplot is None else logplot if not axes: - gs = gridspec.GridSpec(5, 1) - # gs.update(hspace=0.025) - axes = [plt.subplot(gs[0:3, 0])] - axes += [plt.subplot(gs[3:5, 0])] - axes += [axes[1].twinx()] + axes = self.new_two_panel_axes( + n_bottom=2, n_top=(2 if mass_lists else 1), emphasis=emphasis + ) if not tspan: if hasattr(measurement, "potential") and measurement.potential: @@ -103,6 +103,7 @@ def plot_measurement( ms_axes = MSPlotter().plot_measurement( measurement=measurement, ax=axes[0], + axes=[axes[0], axes[3]] if mass_lists else None, tspan=tspan, tspan_bg=tspan_bg, mass_list=mass_list, @@ -126,4 +127,7 @@ def plot_measurement( return axes def plot_vs_potential(self): - """FIXME: This is needed due to assignment in ECMeasurement.__init__""" + """ + + FIXME: This is needed due to assignment in ECMeasurement.__init__ + """ diff --git a/src/ixdat/plotters/ms_plotter.py b/src/ixdat/plotters/ms_plotter.py index f6d1c544..224bfb53 100644 --- a/src/ixdat/plotters/ms_plotter.py +++ b/src/ixdat/plotters/ms_plotter.py @@ -11,8 +11,10 @@ def __init__(self, measurement=None): def plot_measurement( self, + *, measurement=None, ax=None, + axes=None, mass_list=None, mass_lists=None, tspan=None, @@ -26,6 +28,7 @@ def plot_measurement( Args: measurement (MSMeasurement): defaults to the one that initiated the plotter ax (matplotlib axis): Defaults to a new axis + axes (list of matplotlib axis): Left and right y-axes if mass_lists are given mass_list (list of str): The names of the m/z values, eg. ["M2", ...] to plot. Defaults to all of them (measurement.mass_list) mass_lists (list of list of str): Alternately, two lists can be given for @@ -42,9 +45,15 @@ def plot_measurement( """ measurement = measurement or self.measurement if not ax: - ax = self.new_ax(ylabel=f"signal / [{unit}]", xlabel="time / [s]") + ax = ( + axes[0] + if axes + else self.new_ax(ylabel=f"signal / [{unit}]", xlabel="time / [s]") + ) tspan_bg_right = None if mass_lists: + axes = axes or [ax, ax.twinx()] + ax = axes[0] mass_list = mass_lists[0] try: tspan_bg_right = tspan_bg[1] @@ -57,8 +66,6 @@ def plot_measurement( unit_factor = {"pA": 1e12, "nA": 1e9, "uA": 1e6, "A": 1}[unit] # TODO: Real units with a unit module! This should even be able to figure out the # unit prefix to put stuff in a nice 1-to-1e3 range - if not ax: - ax = self.new_ax(ylabel=f"signal / [{unit}]", xlabel="time / [s]") mass_list = mass_list or measurement.mass_list for mass in mass_list: @@ -72,10 +79,9 @@ def plot_measurement( t, v * unit_factor, color=STANDARD_COLORS.get(mass, "k"), label=mass ) if mass_lists: - ax2 = ax.twinx() self.plot_measurement( measurement=measurement, - ax=ax2, + ax=axes[1], mass_list=mass_lists[1], unit=unit, tspan=tspan, @@ -83,9 +89,6 @@ def plot_measurement( logplot=logplot, legend=legend, ) - axes = [ax, ax2] - else: - axes = None if logplot: ax.set_yscale("log") diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index 3a306159..c8c402bd 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -294,7 +294,11 @@ class docstring.) Only if `item` matches neither these strings nor the names of def raw_potential(self): """Return a time-shifted ValueSeries for the raw potential, built first time.""" if not self._raw_potential: - self._find_or_build_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 From 08435606ca3400ac983500e5211f7f40c598ca20 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Wed, 17 Mar 2021 21:44:55 +0000 Subject: [PATCH 041/118] ECMSCyclicVoltammagram and plot_vs_potential for EC-MS --- src/ixdat/plotters/base_mpl_plotter.py | 5 ++ src/ixdat/plotters/ecms_plotter.py | 90 +++++++++++++++++++++--- src/ixdat/plotters/ms_plotter.py | 97 +++++++++++++++++++++++++- src/ixdat/techniques/ec_ms.py | 28 +++++++- 4 files changed, 207 insertions(+), 13 deletions(-) diff --git a/src/ixdat/plotters/base_mpl_plotter.py b/src/ixdat/plotters/base_mpl_plotter.py index e8e29bc0..01ac61e5 100644 --- a/src/ixdat/plotters/base_mpl_plotter.py +++ b/src/ixdat/plotters/base_mpl_plotter.py @@ -40,6 +40,11 @@ def new_two_panel_axes(self, n_bottom=1, n_top=1, emphasis="top"): axes = [plt.subplot(gs[0:3, 0])] axes += [plt.subplot(gs[3:6, 0])] + axes[0].xaxis.set_label_position("top") + axes[0].tick_params( + axis="x", top=True, bottom=False, labeltop=True, labelbottom=False + ) + if n_bottom == 2: axes += [axes[1].twinx()] if n_top == 2: diff --git a/src/ixdat/plotters/ecms_plotter.py b/src/ixdat/plotters/ecms_plotter.py index bc2f1699..6959065c 100644 --- a/src/ixdat/plotters/ecms_plotter.py +++ b/src/ixdat/plotters/ecms_plotter.py @@ -9,6 +9,8 @@ class ECMSPlotter(MPLPlotter): def __init__(self, measurement=None): """Initiate the ECMSPlotter with its default Meausurement to plot""" self.measurement = measurement + self.ec_plotter = ECPlotter(measurement=measurement) + self.ms_plotter = MSPlotter(measurement=measurement) def plot_measurement( self, @@ -88,7 +90,7 @@ def plot_measurement( if hasattr(measurement, "potential") and measurement.potential: # then we have EC data! - ECPlotter().plot_measurement( + self.ec_plotter.plot_measurement( measurement=measurement, axes=[axes[1], axes[2]], tspan=tspan, @@ -100,7 +102,7 @@ def plot_measurement( ) if mass_list or hasattr(measurement, "mass_list"): # then we have MS data! - ms_axes = MSPlotter().plot_measurement( + ms_axes = self.ms_plotter.plot_measurement( measurement=measurement, ax=axes[0], axes=[axes[0], axes[3]] if mass_lists else None, @@ -119,15 +121,85 @@ def plot_measurement( ax.set_ylabel(f"signal / [{unit}]") if ax not in axes: axes.append(ax) - axes[0].xaxis.set_label_position("top") - axes[0].tick_params( - axis="x", top=True, bottom=False, labeltop=True, labelbottom=False - ) axes[1].set_xlim(axes[0].get_xlim()) return axes - def plot_vs_potential(self): - """ + def plot_vs_potential( + self, + *, + measurement=None, + axes=None, + mass_list=None, + mass_lists=None, + tspan=None, + tspan_bg=None, + unit="A", + logplot=None, + legend=True, + emphasis="top", + **kwargs, + ): + """ "Make an EC-MS plot vs time and return the axis handles. + + Allocates tasks to ECPlotter.plot_measurement() and MSPlotter.plot_measurement() - FIXME: This is needed due to assignment in ECMeasurement.__init__ + TODO: add all functionality in the legendary plot_experiment() in EC_MS.Plotting + - variable subplot sizing (emphasizing EC or MS) + - plotting of calibrated data (mol_list instead of mass_list) + - units! + Args: + measurement (ECMSMeasurement): defaults to the measurement to which the + plotter is bound (self.measurement) + axes (list of three matplotlib axes): axes[0] plots the MID data, + axes[1] the current vs potential. By default three axes are made with + axes[0] a top panel with 3/5 the area. + mass_list (list of str): The names of the m/z values, eg. ["M2", ...] to + plot. Defaults to all of them (measurement.mass_list) + mass_lists (list of list of str): Alternately, two lists can be given for + masses in which case one list is plotted on the left y-axis and the other + on the right y-axis of the top panel. + tspan (iter of float): The time interval to plot, wrt measurement.tstamp + tspan_bg (timespan): A timespan for which to assume the signal is at its + background. The average signals during this timespan are subtracted. + If `mass_lists` are given rather than a single `mass_list`, `tspan_bg` + must also be two timespans - one for each axis. Default is `None` for no + background subtraction. + unit (str): the unit for the MS data. Defaults to "A" for Ampere + logplot (bool): Whether to plot the MS data on a log scale (default True + unless mass_lists are given) + legend (bool): Whether to use a legend for the MS data (default True) + emphasis (str or None): "top" for bigger top panel, "bottom" for bigger + bottom panel, None for equal-sized panels + kwargs (dict): Additional kwargs go to all calls of matplotlib's plot() """ + + if not axes: + axes = self.new_two_panel_axes( + n_bottom=1, n_top=(2 if mass_lists else 1), emphasis=emphasis + ) + + self.ec_plotter.plot_vs_potential( + measurement=measurement, tspan=tspan, ax=axes[1], **kwargs + ) + ms_axes = self.ms_plotter.plot_vs( + x_name="potential", + measurement=measurement, + ax=axes[0], + axes=[axes[0], axes[2]] if mass_lists else None, + tspan=tspan, + tspan_bg=tspan_bg, + mass_list=mass_list, + mass_lists=mass_lists, + unit=unit, + logplot=logplot, + legend=legend, + ) + try: + ms_axes.set_ylabel(f"signal / [{unit}]") + except AttributeError: + for ax in ms_axes: + ax.set_ylabel(f"signal / [{unit}]") + if ax not in axes: + axes.append(ax) + axes[1].set_xlim(axes[0].get_xlim()) + return axes diff --git a/src/ixdat/plotters/ms_plotter.py b/src/ixdat/plotters/ms_plotter.py index 224bfb53..a50d49c0 100644 --- a/src/ixdat/plotters/ms_plotter.py +++ b/src/ixdat/plotters/ms_plotter.py @@ -23,7 +23,7 @@ def plot_measurement( logplot=True, legend=True, ): - """Plot m/z signal vs time (MID) data and return the axis handle. + """Plot m/z signal vs time (MID) data and return the axis. Args: measurement (MSMeasurement): defaults to the one that initiated the plotter @@ -97,6 +97,101 @@ def plot_measurement( return axes if axes else ax + def plot_vs( + self, + *, + x_name, + measurement=None, + ax=None, + axes=None, + mass_list=None, + mass_lists=None, + tspan=None, + tspan_bg=None, + unit="A", + logplot=True, + legend=True, + ): + """Plot m/z signal (MID) data against a specified variable and return the axis. + + Args: + x_name (str): Name of the variable to plot on the x-axis + measurement (MSMeasurement): defaults to the one that initiated the plotter + ax (matplotlib axis): Defaults to a new axis + axes (list of matplotlib axis): Left and right y-axes if mass_lists are given + mass_list (list of str): The names of the m/z values, eg. ["M2", ...] to + plot. Defaults to all of them (measurement.mass_list) + mass_lists (list of list of str): Alternately, two lists can be given for + masses in which case one list is plotted on the left y-axis and the other + on the right y-axis of the top panel. + tspan (iter of float): The time interval to plot, wrt measurement.tstamp + tspan_bg (timespan): A timespan for which to assume the signal is at its + background. The average signals during this timespan are subtracted. + If `mass_lists` are given rather than a single `mass_list`, `tspan_bg` + must also be two timespans - one for each axis. Default is `None` for no + background subtraction. + logplot (bool): Whether to plot the MS data on a log scale (default True) + legend (bool): Whether to use a legend for the MS data (default True) + """ + measurement = measurement or self.measurement + if not ax: + ax = ( + axes[0] + if axes + else self.new_ax(ylabel=f"signal / [{unit}]", xlabel=x_name) + ) + tspan_bg_right = None + if mass_lists: + axes = axes or [ax, ax.twinx()] + ax = axes[0] + mass_list = mass_lists[0] + try: + tspan_bg_right = tspan_bg[1] + if isinstance(tspan_bg_right, (float, int)): + raise TypeError + except (KeyError, TypeError): + tspan_bg_right = None + else: + tspan_bg = tspan_bg[0] + unit_factor = {"pA": 1e12, "nA": 1e9, "uA": 1e6, "A": 1}[unit] + # TODO: Real units with a unit module! This should even be able to figure out the + # unit prefix to put stuff in a nice 1-to-1e3 ranges + t, x = measurement.grab(x_name, tspan=tspan, include_endpoints=True) + mass_list = mass_list or measurement.mass_list + for mass in mass_list: + t_mass, v = measurement.grab(mass, tspan=tspan, include_endpoints=False) + if logplot: + v[v < MIN_SIGNAL] = MIN_SIGNAL + if tspan_bg: + _, v_bg = measurement.grab(mass, tspan=tspan_bg) + v = v - np.mean(v_bg) + x_mass = np.interp(t_mass, t, x) + ax.plot( + x_mass, + v * unit_factor, + color=STANDARD_COLORS.get(mass, "k"), + label=mass, + ) + if mass_lists: + self.plot_vs( + x_name=x_name, + measurement=measurement, + ax=axes[1], + mass_list=mass_lists[1], + unit=unit, + tspan=tspan, + tspan_bg=tspan_bg_right, + logplot=logplot, + legend=legend, + ) + + if logplot: + ax.set_yscale("log") + if legend: + ax.legend() + + return axes if axes else ax + MIN_SIGNAL = 1e-14 # So that the bottom half of the plot isn't wasted on log(noise) diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index aa7b4db8..409a5c84 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -1,14 +1,12 @@ """Module for representation and analysis of EC-MS measurements""" from .ec import ECMeasurement from .ms import MSMeasurement +from .cv import CyclicVoltammagram class ECMSMeasurement(ECMeasurement, MSMeasurement): """Class for raw EC-MS functionality. Parents: ECMeasurement and MSMeasurement""" - def __init__(self, name, **kwargs): - super().__init__(name, **kwargs) # FIXME: This seems to just be ECMeasurement. - @property def plotter(self): """The default plotter for ECMSMeasurement is ECMSPlotter""" @@ -18,3 +16,27 @@ def plotter(self): self._plotter = ECMSPlotter(measurement=self) return self._plotter + + def as_cv(self): + self_as_dict = self.as_dict() + + # FIXME: The following lines are only necessary because + # PlaceHolderObject.get_object isn't able to find things in the MemoryBackend + del self_as_dict["s_ids"] + self_as_dict["series_list"] = self.series_list + + return ECMSCyclicVoltammogram.from_dict(self_as_dict) + + +class ECMSCyclicVoltammogram(CyclicVoltammagram, MSMeasurement): + """Class for raw EC-MS functionality. Parents: CyclicVoltammogram, MSMeasurement""" + + @property + def plotter(self): + """The default plotter for ECMSCyclicVoltammogram is ECMSPlotter""" + if not self._plotter: + from ..plotters.ecms_plotter import ECMSPlotter + + self._plotter = ECMSPlotter(measurement=self) + + return self._plotter From 9690f8af9aec9547e9bd655a35c7c07ab5307dd5 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Wed, 17 Mar 2021 23:55:36 +0000 Subject: [PATCH 042/118] bg in plots, both EC and MS __init__'s called in EC-MS --- src/ixdat/db.py | 12 ++++++ src/ixdat/plotters/ecms_plotter.py | 15 ++++++-- src/ixdat/plotters/ms_plotter.py | 45 ++++++++++++++++++----- src/ixdat/techniques/ec_ms.py | 59 ++++++++++++++++++++++++++++++ src/ixdat/techniques/ms.py | 48 ++++++++++++++++++++---- 5 files changed, 159 insertions(+), 20 deletions(-) diff --git a/src/ixdat/db.py b/src/ixdat/db.py index 7757a28a..85147600 100644 --- a/src/ixdat/db.py +++ b/src/ixdat/db.py @@ -231,6 +231,18 @@ def save(self, db=None): db = db or self.db return db.save(self) + @classmethod + def get_all_column_attrs(cls): + """List all attributes of objects of cls that correspond to table columns""" + all_attrs = cls.column_attrs + if cls.extra_column_attrs: + for table, attrs in cls.extra_column_attrs.items(): + all_attrs = all_attrs.union(attrs) + if cls.extra_linkers: + for table, (ref_table, attrs) in cls.extra_linkers.items(): + all_attrs = all_attrs.union(attrs) + return all_attrs + @classmethod def from_dict(cls, obj_as_dict): """Return an object built from its serialization.""" diff --git a/src/ixdat/plotters/ecms_plotter.py b/src/ixdat/plotters/ecms_plotter.py index 6959065c..ef88c6cb 100644 --- a/src/ixdat/plotters/ecms_plotter.py +++ b/src/ixdat/plotters/ecms_plotter.py @@ -21,6 +21,7 @@ def plot_measurement( mass_lists=None, tspan=None, tspan_bg=None, + removebackground=None, unit="A", V_str=None, J_str=None, @@ -58,6 +59,8 @@ def plot_measurement( If `mass_lists` are given rather than a single `mass_list`, `tspan_bg` must also be two timespans - one for each axis. Default is `None` for no background subtraction. + removebackground (bool): Whether otherwise to subtract pre-determined + background signals if available. Defaults to (not logplot) unit (str): the unit for the MS data. Defaults to "A" for Ampere V_str (str): The name of the value to plot on the lower left y-axis. Defaults to the name of the series `measurement.potential` @@ -108,11 +111,13 @@ def plot_measurement( axes=[axes[0], axes[3]] if mass_lists else None, tspan=tspan, tspan_bg=tspan_bg, + removebackground=removebackground, mass_list=mass_list, mass_lists=mass_lists, unit=unit, logplot=logplot, legend=legend, + **kwargs, ) try: ms_axes.set_ylabel(f"signal / [{unit}]") @@ -133,8 +138,9 @@ def plot_vs_potential( mass_lists=None, tspan=None, tspan_bg=None, + removebackground=None, unit="A", - logplot=None, + logplot=False, legend=True, emphasis="top", **kwargs, @@ -164,9 +170,10 @@ def plot_vs_potential( If `mass_lists` are given rather than a single `mass_list`, `tspan_bg` must also be two timespans - one for each axis. Default is `None` for no background subtraction. + removebackground (bool): Whether otherwise to subtract pre-determined + background signals if available. Defaults to (not logplot) unit (str): the unit for the MS data. Defaults to "A" for Ampere - logplot (bool): Whether to plot the MS data on a log scale (default True - unless mass_lists are given) + logplot (bool): Whether to plot the MS data on a log scale (default False) legend (bool): Whether to use a legend for the MS data (default True) emphasis (str or None): "top" for bigger top panel, "bottom" for bigger bottom panel, None for equal-sized panels @@ -188,11 +195,13 @@ def plot_vs_potential( axes=[axes[0], axes[2]] if mass_lists else None, tspan=tspan, tspan_bg=tspan_bg, + removebackground=removebackground, mass_list=mass_list, mass_lists=mass_lists, unit=unit, logplot=logplot, legend=legend, + **kwargs, ) try: ms_axes.set_ylabel(f"signal / [{unit}]") diff --git a/src/ixdat/plotters/ms_plotter.py b/src/ixdat/plotters/ms_plotter.py index a50d49c0..70d8cd1c 100644 --- a/src/ixdat/plotters/ms_plotter.py +++ b/src/ixdat/plotters/ms_plotter.py @@ -19,9 +19,11 @@ def plot_measurement( mass_lists=None, tspan=None, tspan_bg=None, + removebackground=None, unit="A", logplot=True, legend=True, + **kwargs, ): """Plot m/z signal vs time (MID) data and return the axis. @@ -40,10 +42,15 @@ def plot_measurement( If `mass_lists` are given rather than a single `mass_list`, `tspan_bg` must also be two timespans - one for each axis. Default is `None` for no background subtraction. + removebackground (bool): Whether otherwise to subtract pre-determined + background signals if available. Defaults to (not logplot) logplot (bool): Whether to plot the MS data on a log scale (default True) legend (bool): Whether to use a legend for the MS data (default True) + kwargs: extra key-word args are passed on to matplotlib's plot() """ measurement = measurement or self.measurement + if removebackground is None: + removebackground = not logplot if not ax: ax = ( axes[0] @@ -69,14 +76,21 @@ def plot_measurement( mass_list = mass_list or measurement.mass_list for mass in mass_list: - t, v = measurement.grab(mass, tspan=tspan, include_endpoints=False) + t, v = measurement.grab_signal( + mass, + tspan=tspan, + t_bg=tspan_bg, + removebackground=removebackground, + include_endpoints=False, + ) if logplot: v[v < MIN_SIGNAL] = MIN_SIGNAL - if tspan_bg: - _, v_bg = measurement.grab(mass, tspan=tspan_bg) - v = v - np.mean(v_bg) ax.plot( - t, v * unit_factor, color=STANDARD_COLORS.get(mass, "k"), label=mass + t, + v * unit_factor, + color=STANDARD_COLORS.get(mass, "k"), + label=mass, + **kwargs, ) if mass_lists: self.plot_measurement( @@ -88,6 +102,7 @@ def plot_measurement( tspan_bg=tspan_bg_right, logplot=logplot, legend=legend, + **kwargs, ) if logplot: @@ -108,9 +123,11 @@ def plot_vs( mass_lists=None, tspan=None, tspan_bg=None, + removebackground=None, unit="A", logplot=True, legend=True, + **kwargs, ): """Plot m/z signal (MID) data against a specified variable and return the axis. @@ -130,10 +147,15 @@ def plot_vs( If `mass_lists` are given rather than a single `mass_list`, `tspan_bg` must also be two timespans - one for each axis. Default is `None` for no background subtraction. + removebackground (bool): Whether otherwise to subtract pre-determined + background signals if available logplot (bool): Whether to plot the MS data on a log scale (default True) legend (bool): Whether to use a legend for the MS data (default True) + kwargs: key-word args are passed on to matplotlib's plot() """ measurement = measurement or self.measurement + if removebackground is None: + removebackground = not logplot if not ax: ax = ( axes[0] @@ -159,18 +181,22 @@ def plot_vs( t, x = measurement.grab(x_name, tspan=tspan, include_endpoints=True) mass_list = mass_list or measurement.mass_list for mass in mass_list: - t_mass, v = measurement.grab(mass, tspan=tspan, include_endpoints=False) + t_mass, v = measurement.grab_signal( + mass, + tspan=tspan, + t_bg=tspan_bg, + removebackground=removebackground, + include_endpoints=False, + ) if logplot: v[v < MIN_SIGNAL] = MIN_SIGNAL - if tspan_bg: - _, v_bg = measurement.grab(mass, tspan=tspan_bg) - v = v - np.mean(v_bg) x_mass = np.interp(t_mass, t, x) ax.plot( x_mass, v * unit_factor, color=STANDARD_COLORS.get(mass, "k"), label=mass, + **kwargs, ) if mass_lists: self.plot_vs( @@ -183,6 +209,7 @@ def plot_vs( tspan_bg=tspan_bg_right, logplot=logplot, legend=legend, + **kwargs, ) if logplot: diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index 409a5c84..c38b2398 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -7,6 +7,36 @@ class ECMSMeasurement(ECMeasurement, MSMeasurement): """Class for raw EC-MS functionality. Parents: ECMeasurement and MSMeasurement""" + extra_column_attrs = { + # FIXME: It would be more elegant if this carried over from both parents + # That might require some custom inheritance definition... + "ecms_meaurements": { + "mass_aliases", + "signal_bgs", + "ec_technique", + "RE_vs_RHE", + "R_Ohm", + "raw_potential_names", + "A_el", + "raw_current_names", + }, + } + + def __init__(self, **kwargs): + """FIXME: Passing the right key-word arguments on is a mess""" + ec_kwargs = { + k: v for k, v in kwargs.items() if k in ECMeasurement.get_all_column_attrs() + } + # FIXME: I think the line below could be avoided with a PlaceHolderObject that + # works together with MemoryBackend + ec_kwargs.update(series_list=kwargs["series_list"]) + ECMeasurement.__init__(self, **ec_kwargs) + ms_kwargs = { + k: v for k, v in kwargs.items() if k in MSMeasurement.get_all_column_attrs() + } + ms_kwargs.update(series_list=kwargs["series_list"]) + MSMeasurement.__init__(self, **ms_kwargs) + @property def plotter(self): """The default plotter for ECMSMeasurement is ECMSPlotter""" @@ -31,6 +61,35 @@ def as_cv(self): class ECMSCyclicVoltammogram(CyclicVoltammagram, MSMeasurement): """Class for raw EC-MS functionality. Parents: CyclicVoltammogram, MSMeasurement""" + extra_column_attrs = { + # FIXME: It would be more elegant if this carried over from both parents + # That might require some custom inheritance definition... + "ecms_meaurements": { + "mass_aliases", + "signal_bgs", + "ec_technique", + "RE_vs_RHE", + "R_Ohm", + "raw_potential_names", + "A_el", + "raw_current_names", + }, + } + + def __init__(self, **kwargs): + """FIXME: Passing the right key-word arguments on is a mess""" + ec_kwargs = { + k: v for k, v in kwargs.items() if k in ECMeasurement.get_all_column_attrs() + } + ec_kwargs.update(series_list=kwargs["series_list"]) + ECMeasurement.__init__(self, **ec_kwargs) + ms_kwargs = { + k: v for k, v in kwargs.items() if k in MSMeasurement.get_all_column_attrs() + } + ms_kwargs.update(series_list=kwargs["series_list"]) + MSMeasurement.__init__(self, **ms_kwargs) + self.plot = self.plotter.plot_vs_potential + @property def plotter(self): """The default plotter for ECMSCyclicVoltammogram is ECMSPlotter""" diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index 131e6744..88b41144 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -13,10 +13,13 @@ class MSMeasurement(Measurement): extra_column_attrs = { "ms_meaurements": { "mass_aliases", - } + "signal_bgs", + }, } - def __init__(self, name, mass_aliases=None, **kwargs): + def __init__( + self, name, mass_aliases=None, signal_bgs=None, tspan_bg=None, **kwargs + ): """Initializes a MS Measurement Args: @@ -25,10 +28,15 @@ def __init__(self, name, mass_aliases=None, **kwargs): corresponds to the respective signal name. mass_aliases (dict): {mass: mass_name} for any masses that do not have the standard 'M' format used by ixdat. + signal_bgs (dict): {mass: S_bg} where S_bg is the background signal + in [A] for the mass (typically set with a timespan by `set_bg()`) + tspan_bg (timespan): background time used to set masses """ super().__init__(name, **kwargs) self.calibration = None # TODO: Not final implementation self.mass_aliases = mass_aliases or {} + self.signal_bgs = signal_bgs or {} + self.tspan_bg = tspan_bg def __getitem__(self, item): """Adds to Measurement's lookup to check if item is an alias for a mass""" @@ -40,7 +48,29 @@ def __getitem__(self, item): else: raise - def grab_signal(self, signal_name, tspan=None, t_bg=None): + def set_bg(self, tspan_bg=None, mass_list=None): + """Set background values for mass_list to the average signal during tspan_bg.""" + mass_list = mass_list or self.mass_list + tspan_bg = tspan_bg or self.tspan_bg + for mass in mass_list: + t, v = self.grab(mass, tspan_bg) + self.signal_bgs[mass] = np.mean(v) + + def reset_bg(self, mass_list=None): + """Reset background values for the masses in mass_list""" + mass_list = mass_list or self.mass_list + for mass in mass_list: + if mass in self.signal_bgs: + del self.signal_bgs[mass] + + def grab_signal( + self, + signal_name, + tspan=None, + t_bg=None, + removebackground=False, + include_endpoints=False, + ): """Returns raw signal for a given signal name Args: @@ -48,10 +78,15 @@ def grab_signal(self, signal_name, tspan=None, t_bg=None): tspan (list): Timespan for which the signal is returned. t_bg (list): Timespan that corresponds to the background signal. If not given, no background is subtracted. + removebackground (bool): Whether to remove a pre-set background if available """ - time, value = self.grab(signal_name, tspan=tspan) + time, value = self.grab( + signal_name, tspan=tspan, include_endpoints=include_endpoints + ) if t_bg is None: + if removebackground and signal_name in self.signal_bgs: + return time, value - self.signal_bgs[signal_name] return time, value else: @@ -100,10 +135,7 @@ def as_mass(self, item): @property def plotter(self): - """The default plotter for ECMeasurement is ECPlotter""" + """The default plotter for MSMeasurement is MSPlotter""" if not self._plotter: - from ..plotters.ec_plotter import ECPlotter - self._plotter = MSPlotter(measurement=self) - return self._plotter From d0317b9171328be7790e519bddb511f3557dd40a Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Thu, 18 Mar 2021 13:27:26 +0000 Subject: [PATCH 043/118] EC-MS exporting and saving --- src/ixdat/db.py | 4 ++-- src/ixdat/exporters/__init__.py | 1 + src/ixdat/exporters/ec_exporter.py | 6 ++++-- src/ixdat/exporters/ecms_exporter.py | 16 ++++++++++++++ src/ixdat/measurements.py | 11 +++++++++- src/ixdat/readers/ixdat_csv.py | 7 ++++-- src/ixdat/techniques/ec.py | 2 +- src/ixdat/techniques/ec_ms.py | 32 ++++++++++++++++++++++------ 8 files changed, 65 insertions(+), 14 deletions(-) create mode 100644 src/ixdat/exporters/ecms_exporter.py diff --git a/src/ixdat/db.py b/src/ixdat/db.py index 85147600..53e8c252 100644 --- a/src/ixdat/db.py +++ b/src/ixdat/db.py @@ -239,8 +239,8 @@ def get_all_column_attrs(cls): for table, attrs in cls.extra_column_attrs.items(): all_attrs = all_attrs.union(attrs) if cls.extra_linkers: - for table, (ref_table, attrs) in cls.extra_linkers.items(): - all_attrs = all_attrs.union(attrs) + for table, (ref_table, attr) in cls.extra_linkers.items(): + all_attrs.add(attr) return all_attrs @classmethod diff --git a/src/ixdat/exporters/__init__.py b/src/ixdat/exporters/__init__.py index 02ca5a19..dcb14834 100644 --- a/src/ixdat/exporters/__init__.py +++ b/src/ixdat/exporters/__init__.py @@ -1,2 +1,3 @@ from .csv_exporter import CSVExporter from .ec_exporter import ECExporter +from .ecms_exporter import ECMSExporter diff --git a/src/ixdat/exporters/ec_exporter.py b/src/ixdat/exporters/ec_exporter.py index a2e0bfa7..86445323 100644 --- a/src/ixdat/exporters/ec_exporter.py +++ b/src/ixdat/exporters/ec_exporter.py @@ -10,8 +10,10 @@ def default_v_list(self): v_list = [ self.measurement.E_str, self.measurement.I_str, - self.measurement.V_str, - self.measurement.J_str, self.measurement.sel_str, ] + if (self.measurement.RE_vs_RHE is not None) or self.measurement.R_Ohm: + v_list.append(self.measurement.V_str) + if self.measurement.A_el: + v_list.append(self.measurement.J_str) return v_list diff --git a/src/ixdat/exporters/ecms_exporter.py b/src/ixdat/exporters/ecms_exporter.py new file mode 100644 index 00000000..000af082 --- /dev/null +++ b/src/ixdat/exporters/ecms_exporter.py @@ -0,0 +1,16 @@ +from .csv_exporter import CSVExporter +from .ec_exporter import ECExporter + + +class ECMSExporter(CSVExporter): + """A CSVExporter that by default exports potential, current, selector, and all MID""" + + @property + def default_v_list(self): + """The default v_list for ECExporter is V_str, J_str, and sel_str""" + v_list = ( + ECExporter(measurement=self.measurement).default_v_list + + self.measurement.mass_list + ) + + return v_list diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index e961f774..07d75495 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -330,7 +330,16 @@ def series_list(self): @property def data_objects(self): """This is what the DB backend knows to save separately, here the series""" - return self.series_list + # TimeSeries have to go first, so that ValueSeries are saved with the right t_id! + data_object_list = self.time_series + for s in self.series_list: + if s not in data_object_list: + if s.tseries not in data_object_list: + # FIXME: some tseries, likely with duplicate data, seem to not + # make it into series_list + data_object_list.append(s.tseries) + data_object_list.append(s) + return data_object_list @property def component_measurements(self): diff --git a/src/ixdat/readers/ixdat_csv.py b/src/ixdat/readers/ixdat_csv.py index ce9e8224..9ccb1b96 100644 --- a/src/ixdat/readers/ixdat_csv.py +++ b/src/ixdat/readers/ixdat_csv.py @@ -10,7 +10,7 @@ regular_expressions = { "tstamp": r"tstamp = ([0-9\.]+)", - "technique": r"technique = (\w+)", + "technique": r"technique = ([A-Za-z\-]+)\n", "N_header_lines": r"N_header_lines = ([0-9]+)", "backend_name": r"backend_name = (\w+)", "id": r"id = ([0-9]+)", @@ -214,7 +214,10 @@ def process_data_line(self, line): try: value = float(value_string) except ValueError: - raise ReadError(f"can't parse value string '{value_string}'") + # That is probably because different columns are different length. + # so we just skip it! + continue + # raise ReadError(f"can't parse value string '{value_string}'") self.column_data[name].append(value) def print_header(self): diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index c8c402bd..f8c14d12 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -399,7 +399,7 @@ def calibrate(self, RE_vs_RHE=None, A_el=None, R_Ohm=None): A_el (float): electrode area in [cm^2] R_Ohm (float): ohmic drop resistance in [Ohm] """ - if RE_vs_RHE: + 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) diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index c38b2398..33863884 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -2,6 +2,7 @@ from .ec import ECMeasurement from .ms import MSMeasurement from .cv import CyclicVoltammagram +from ..exporters.ecms_exporter import ECMSExporter class ECMSMeasurement(ECMeasurement, MSMeasurement): @@ -27,14 +28,15 @@ def __init__(self, **kwargs): ec_kwargs = { k: v for k, v in kwargs.items() if k in ECMeasurement.get_all_column_attrs() } - # FIXME: I think the line below could be avoided with a PlaceHolderObject that - # works together with MemoryBackend - ec_kwargs.update(series_list=kwargs["series_list"]) - ECMeasurement.__init__(self, **ec_kwargs) ms_kwargs = { k: v for k, v in kwargs.items() if k in MSMeasurement.get_all_column_attrs() } - ms_kwargs.update(series_list=kwargs["series_list"]) + # FIXME: I think the line below could be avoided with a PlaceHolderObject that + # works together with MemoryBackend + if "series_list" in kwargs: + ec_kwargs.update(series_list=kwargs["series_list"]) + ms_kwargs.update(series_list=kwargs["series_list"]) + ECMeasurement.__init__(self, **ec_kwargs) MSMeasurement.__init__(self, **ms_kwargs) @property @@ -47,6 +49,13 @@ def plotter(self): return self._plotter + @property + def exporter(self): + """The default plotter for ECMSMeasurement is ECMSExporter""" + if not self._exporter: + self._exporter = ECMSExporter(measurement=self) + return self._exporter + def as_cv(self): self_as_dict = self.as_dict() @@ -59,7 +68,11 @@ def as_cv(self): class ECMSCyclicVoltammogram(CyclicVoltammagram, MSMeasurement): - """Class for raw EC-MS functionality. Parents: CyclicVoltammogram, MSMeasurement""" + """Class for raw EC-MS functionality. Parents: CyclicVoltammogram, MSMeasurement + + FIXME: Maybe this class should instead inherit from ECMSMeasurement and + just add the CyclicVoltammogram functionality? + """ extra_column_attrs = { # FIXME: It would be more elegant if this carried over from both parents @@ -99,3 +112,10 @@ def plotter(self): self._plotter = ECMSPlotter(measurement=self) return self._plotter + + @property + def exporter(self): + """The default plotter for ECMSCyclicVoltammogram is ECMSExporter""" + if not self._exporter: + self._exporter = ECMSExporter(measurement=self) + return self._exporter From b509ebd629824d714797cbe94e58f2e19e0effdf Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Thu, 18 Mar 2021 13:33:43 +0000 Subject: [PATCH 044/118] update docs, v0.0.10 --- docs/source/exporter_docs/index.rst | 11 +++++++++-- docs/source/technique_docs/ec_ms.rst | 4 ++++ docs/source/technique_docs/electrochemistry.rst | 2 ++ src/ixdat/__init__.py | 2 +- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/source/exporter_docs/index.rst b/docs/source/exporter_docs/index.rst index 2df82f23..45015bff 100644 --- a/docs/source/exporter_docs/index.rst +++ b/docs/source/exporter_docs/index.rst @@ -9,15 +9,22 @@ measurement using its default ``ECExporter``.): https://github.com/ixdat/tutorials/blob/main/loading_appending_and_saving/co_strip.csv The ``csv_exporter`` module -........................ +........................... .. automodule:: ixdat.exporters.csv_exporter :members: The ``ec_exporter`` module -........................ +.......................... .. automodule:: ixdat.exporters.ec_exporter :members: +The ``ecms_exporter`` module +............................ + +.. automodule:: ixdat.exporters.ecms_exporter + :members: + + diff --git a/docs/source/technique_docs/ec_ms.rst b/docs/source/technique_docs/ec_ms.rst index b696fd09..2400ae67 100644 --- a/docs/source/technique_docs/ec_ms.rst +++ b/docs/source/technique_docs/ec_ms.rst @@ -17,6 +17,10 @@ An ``ECMSMeasurement`` can be created either by adding an ``ECMeasurement`` and using the ``+`` operator, or by directly importing data using an EC-MS :ref:`reader ` such as "zilien". +``ECMSCyclicVoltammogram`` adds to ``ECMSMeasurement`` the tools for selecting and analyzing data +based on an electrochemical cyclic voltammatry program that are implemented in ``CyclicVoltammogram`` +(see :ref:`cyclic_voltammetry`). + Deconvolution, described in a publication under review, is implemented in the deconvolution module, in a class inheriting from ``ECMSMeasurement``. diff --git a/docs/source/technique_docs/electrochemistry.rst b/docs/source/technique_docs/electrochemistry.rst index 1792a2b6..f9b10a8b 100644 --- a/docs/source/technique_docs/electrochemistry.rst +++ b/docs/source/technique_docs/electrochemistry.rst @@ -42,6 +42,8 @@ Source: https://github.com/ixdat/ixdat/tree/user_ready/src/ixdat/techniques/ec.p .. automodule:: ixdat.techniques.ec :members: +.. _`cyclic_voltammetry`: + The ``cv`` module ----------------- Source: https://github.com/ixdat/ixdat/tree/user_ready/src/ixdat/techniques/cv.py diff --git a/src/ixdat/__init__.py b/src/ixdat/__init__.py index 7c242742..d605a4a8 100644 --- a/src/ixdat/__init__.py +++ b/src/ixdat/__init__.py @@ -1,6 +1,6 @@ """initialize ixdat, giving top-level access to a few of the important structures """ -__version__ = "0.0.9" +__version__ = "0.0.10" __title__ = "ixdat" __description__ = "The in-situ experimental data tool" __url__ = "https://github.com/ixdat/ixdat" From 40f6544707c21a0f431fd55da2f08fb7a42356e5 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Mon, 22 Mar 2021 16:56:05 +0000 Subject: [PATCH 045/118] debugging: biologic encoding, EC-MS component_measurements, sel_str overwrite --- src/ixdat/measurements.py | 5 ++++- src/ixdat/readers/biologic.py | 2 +- src/ixdat/techniques/ec_ms.py | 5 ++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index 07d75495..c30b9aac 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -32,6 +32,10 @@ class Measurement(Saveable): "component_measurements": ("measurements", "m_ids"), } + sel_str = None # the default thing to select on. + # FIXME: this is here because otherwise MSMeasurement.__init__ overwrites what it + # gets set to by ECMeasurement.__init__ in ECMSMeasurement.__init__ + def __init__( self, name, @@ -88,7 +92,6 @@ def __init__( component_measurements, m_ids, cls=Measurement ) self.tstamp = tstamp - self.sel_str = None # the default thing to select on. # defining these methods here gets them the right docstrings :D self.plot_measurement = self.plotter.plot_measurement diff --git a/src/ixdat/readers/biologic.py b/src/ixdat/readers/biologic.py index a832f2b7..e118fc1d 100644 --- a/src/ixdat/readers/biologic.py +++ b/src/ixdat/readers/biologic.py @@ -104,7 +104,7 @@ def read(self, path_to_file, name=None, cls=None, **kwargs): self.name = name or path_to_file.name self.path_to_file = path_to_file self.measurement_class = cls or ECMeasurement - with open(self.path_to_file, "r") as f: + with open(self.path_to_file, "r", encoding="ISO-8859-1") as f: for line in f: self.process_line(line) for name in self.column_names: diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index 33863884..39fa9da7 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -31,11 +31,14 @@ def __init__(self, **kwargs): ms_kwargs = { k: v for k, v in kwargs.items() if k in MSMeasurement.get_all_column_attrs() } - # FIXME: I think the line below could be avoided with a PlaceHolderObject that + # FIXME: I think the lines below could be avoided with a PlaceHolderObject that # works together with MemoryBackend if "series_list" in kwargs: ec_kwargs.update(series_list=kwargs["series_list"]) ms_kwargs.update(series_list=kwargs["series_list"]) + if "component_measurements" in kwargs: + ec_kwargs.update(component_measurements=kwargs["component_measurements"]) + ms_kwargs.update(component_measurements=kwargs["component_measurements"]) ECMeasurement.__init__(self, **ec_kwargs) MSMeasurement.__init__(self, **ms_kwargs) From 365cc799b56c4caee89dea298e164f5f98a9aafe Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Tue, 23 Mar 2021 19:19:41 +0000 Subject: [PATCH 046/118] debug for all EC + MS reader combos --- src/ixdat/measurements.py | 7 +++++++ src/ixdat/readers/autolab.py | 27 ++++++++++++++++++++++++--- src/ixdat/readers/biologic.py | 3 +++ src/ixdat/readers/reading_tools.py | 9 ++++++--- 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index c30b9aac..7763bec0 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -756,7 +756,14 @@ def __add__(self, other): all the raw series (or their placeholders) are just stored in the lists. TODO: Make sure with tests this is okay, differentiate using | operator if not. """ + # First we prepare a dictionary for all but the series_list. + # This has both dicts, but prioritizes self's dict for all that appears twice. obj_as_dict = self.as_dict() + other_as_dict = other.as_dict() + for k, v in other_as_dict.items(): + # Looking forward to the "|" operator! + if k not in obj_as_dict: + obj_as_dict[k] = v new_name = self.name + " AND " + other.name new_technique = get_combined_technique(self.technique, other.technique) diff --git a/src/ixdat/readers/autolab.py b/src/ixdat/readers/autolab.py index 01c9e6d3..e06f83fd 100644 --- a/src/ixdat/readers/autolab.py +++ b/src/ixdat/readers/autolab.py @@ -3,13 +3,27 @@ import re from pathlib import Path import pandas as pd -from .reading_tools import prompt_for_tstamp, series_list_from_dataframe +from .reading_tools import ( + prompt_for_tstamp, + series_list_from_dataframe, + STANDARD_TIMESTAMP_FORM, + timestamp_string_to_tstamp, +) class NovaASCIIReader: """A reader for ascii files exported by Autolab's Nova software""" - def read(self, path_to_file, cls=None, name=None, **kwargs): + def read( + self, + path_to_file, + cls=None, + name=None, + tstamp=None, + timestring=None, + timestring_form=STANDARD_TIMESTAMP_FORM, + **kwargs + ): """read the ascii export from Autolab's Nova software Args: @@ -18,11 +32,18 @@ def read(self, path_to_file, cls=None, name=None, **kwargs): cls (Measurement subclass): The Measurement class to return an object of. Defaults to `ECMeasurement` and should probably be a subclass thereof in any case. + tstamp (float): timestamp of the measurement, if known + timestring (str): timestring describing the timestamp of the measurement + timestring_form (str): form of the timestring. Default is "%d/%m/%Y %H:%M:%S" **kwargs (dict): Key-word arguments are passed to cls.__init__ """ self.path_to_file = Path(path_to_file) name = name or self.path_to_file.name - tstamp = prompt_for_tstamp(self.path_to_file) + if not tstamp: + if timestring: + tstamp = timestamp_string_to_tstamp(timestring, form=timestring_form) + else: + tstamp = prompt_for_tstamp(self.path_to_file) dataframe = pd.read_csv(self.path_to_file, delimiter=";") diff --git a/src/ixdat/readers/biologic.py b/src/ixdat/readers/biologic.py index e118fc1d..badd4b3a 100644 --- a/src/ixdat/readers/biologic.py +++ b/src/ixdat/readers/biologic.py @@ -222,6 +222,9 @@ def print_header(self): header = "".join(self.header_lines) print(header) + def __repr__(self): + return f"{self.__class__.__name__}({self.path_to_file})" + def get_column_unit(column_name): """Return the unit name of a .mpt column, i.e the part of the name after the '/'""" diff --git a/src/ixdat/readers/reading_tools.py b/src/ixdat/readers/reading_tools.py index df3c4bd0..002f914e 100644 --- a/src/ixdat/readers/reading_tools.py +++ b/src/ixdat/readers/reading_tools.py @@ -4,6 +4,7 @@ import time import urllib.request from ..config import CFG +from ..exceptions import ReadError from ..measurements import TimeSeries, ValueSeries @@ -34,12 +35,14 @@ def timestamp_string_to_tstamp( continue except ValueError: continue - - tstamp = time.mktime(struct) + try: + tstamp = time.mktime(struct) + except TypeError: + raise ReadError(f"couldn't parse timestamp_string='{timestamp_string}')") return tstamp -def prompt_for_tstamp(path_to_file, default="creation", form=USA_TIMESTAMP_FORM): +def prompt_for_tstamp(path_to_file, default="creation", form=STANDARD_TIMESTAMP_FORM): """Return the tstamp resulting from a prompt to enter a timestamp, or a default Args: From 1dda77700625213ab4e85fd25a6f8094cb14962c Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Tue, 23 Mar 2021 21:21:44 +0000 Subject: [PATCH 047/118] zilien tmp reader --- src/ixdat/readers/__init__.py | 3 +- src/ixdat/readers/zilien.py | 74 +++++++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/ixdat/readers/__init__.py b/src/ixdat/readers/__init__.py index b8dad071..669ed9fa 100644 --- a/src/ixdat/readers/__init__.py +++ b/src/ixdat/readers/__init__.py @@ -20,7 +20,7 @@ from .cinfdata import CinfdataTXTReader # ec-ms -from .zilien import ZilienTSVReader +from .zilien import ZilienTSVReader, ZilienTMPReader from .ec_ms_pkl import EC_MS_CONVERTER READER_CLASSES = { @@ -31,5 +31,6 @@ "pfeiffer": PVMassSpecReader, "cinfdata": CinfdataTXTReader, "zilien": ZilienTSVReader, + "zilien_tmp": ZilienTMPReader, "EC_MS": EC_MS_CONVERTER, } diff --git a/src/ixdat/readers/zilien.py b/src/ixdat/readers/zilien.py index 8df5cba7..e428bc6c 100644 --- a/src/ixdat/readers/zilien.py +++ b/src/ixdat/readers/zilien.py @@ -1,7 +1,12 @@ -from . import TECHNIQUE_CLASSES +from pathlib import Path +import re +import pandas as pd +from ..data_series import TimeSeries, ValueSeries +from ..techniques.ec_ms import ECMSMeasurement +from .reading_tools import timestamp_string_to_tstamp from .ec_ms_pkl import measurement_from_ec_ms_dataset -ECMSMeasurement = TECHNIQUE_CLASSES["EC-MS"] +ZILIEN_TIMESTAMP_FORM = "%Y-%m-%d %H_%M_%S" # like 2021-03-15 18_50_10 class ZilienTSVReader: @@ -22,10 +27,73 @@ def read(self, path_to_file, cls=None, name=None, **kwargs): name=name, reader=self, technique="EC-MS", - **kwargs + **kwargs, ) +class ZilienTMPReader: + """A class for stitching the files in a Zilien tmp directory to an ECMSMeasurement + + This is necessary because Zilien often crashes, leaving only the tmp directory. + This is less advanced but more readable than the Spectro Inlets stitching solution. + """ + + def __init__(self, path_to_tmp_dir=None): + self.path_to_tmp_dir = Path(path_to_tmp_dir) if path_to_tmp_dir else None + + def read(self, path_to_tmp_dir, cls=None, **kwargs): + """Make a measurement from all the single-value .tsv files in a Zilien tmp dir + + Args: + path_to_tmp_dir (Path or str): the path to the tmp dir + cls (Measurement class): Defaults to ECMSMeasurement + """ + if path_to_tmp_dir: + self.path_to_tmp_dir = Path(path_to_tmp_dir) + cls = cls or ECMSMeasurement + name = self.path_to_tmp_dir.parent.name + timestamp_string = name[:19] # the zilien timestamp is the first 19 chars + tstamp = timestamp_string_to_tstamp( + timestamp_string, form=ZILIEN_TIMESTAMP_FORM + ) + series_list = [] + for tmp_file in self.path_to_tmp_dir.iterdir(): + series_list += series_list_from_tmp(tmp_file) + obj_as_dict = { + "name": name, + "tstamp": tstamp, + "series_list": series_list, + "technique": "EC-MS", + "reader": self, + } + obj_as_dict.update(kwargs) + return cls.from_dict(obj_as_dict) + + +def series_list_from_tmp(path_to_file): + """Return [ValueSeries, TimeSeries] with the data in a zilien tmp .tsv file""" + file_name = Path(path_to_file).name + timestamp_string = file_name[:19] # the zilien timestamp form is 19 chars long + tstamp = timestamp_string_to_tstamp(timestamp_string, form=ZILIEN_TIMESTAMP_FORM) + column_match = re.search(r"\.([^\.]+)\.data", file_name) + if not column_match: + print(f"could not find column name in {path_to_file}") + return [] + v_name = column_match.group(1) + mass_match = re.search("M[0-9]+", v_name) + if mass_match: + v_name = mass_match.group() + unit = "A" + else: + unit = None + t_name = v_name + "-x" + df = pd.read_csv(path_to_file, delimiter="\t", names=[t_name, v_name], header=0) + t_data, v_data = df[t_name].to_numpy(), df[v_name].to_numpy() + tseries = TimeSeries(name=t_name, unit_name="s", data=t_data, tstamp=tstamp) + vseries = ValueSeries(name=v_name, unit_name=unit, data=v_data, tseries=tseries) + return [tseries, vseries] + + if __name__ == "__main__": """Module demo here. From 08dfa0c79400259fb3c5456d8773413138eb5e0c Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Wed, 14 Apr 2021 23:08:02 +0100 Subject: [PATCH 048/118] fix duplicate Ewe/V and I/mA in EC_MS imports bug --- src/ixdat/readers/ec_ms_pkl.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/ixdat/readers/ec_ms_pkl.py b/src/ixdat/readers/ec_ms_pkl.py index ee3b07a7..f22c89c4 100644 --- a/src/ixdat/readers/ec_ms_pkl.py +++ b/src/ixdat/readers/ec_ms_pkl.py @@ -56,6 +56,13 @@ def measurement_from_ec_ms_dataset( reader (Reader object): typically what calls this funciton with its read() method """ + if "Ewe/V" in ec_ms_dict and "/V" in ec_ms_dict: + # EC_MS duplicates the latter as the former, so here we delete it: + del ec_ms_dict["/V"] + if "I/mA" in ec_ms_dict and "/mA" in ec_ms_dict: + # EC_MS duplicates the latter as the former, so here we delete it: + del ec_ms_dict["/mA"] + cols_str = ec_ms_dict["data_cols"] cols_list = [] @@ -72,9 +79,11 @@ def measurement_from_ec_ms_dataset( TimeSeries("time/s", "s", ec_ms_dict["time/s"], ec_ms_dict["tstamp"]) ) - measurement = Measurement("tseries_ms", technique="EC_MS", series_list=cols_list) + tseries_meas = Measurement("tseries_ms", technique="EC_MS", series_list=cols_list) for col in cols_str: + if col not in ec_ms_dict: + continue if col.endswith("-y"): unit_name = "A" if col.startswith("M") else "" cols_list.append( @@ -82,16 +91,16 @@ def measurement_from_ec_ms_dataset( col[:-2], unit_name=unit_name, data=ec_ms_dict[col], - tseries=measurement[col[:-1] + "x"], + tseries=tseries_meas[col[:-1] + "x"], ) ) - if col in BIOLOGIC_COLUMN_NAMES and col not in measurement.series_names: + if col in BIOLOGIC_COLUMN_NAMES and col not in tseries_meas.series_names: cols_list.append( ValueSeries( name=col, data=ec_ms_dict[col], unit_name=get_column_unit(col), - tseries=measurement["time/s"], + tseries=tseries_meas["time/s"], ) ) From 7f270063e8e18b8eafe020960a0aa98104ec9f98 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Thu, 15 Apr 2021 15:44:20 +0100 Subject: [PATCH 049/118] write internal and semiinternal calibration functions --- src/ixdat/constants.py | 34 +++++- src/ixdat/techniques/ec_ms.py | 73 +++++++++++- src/ixdat/techniques/ms.py | 201 +++++++++++++++++++++++++++++++++- 3 files changed, 305 insertions(+), 3 deletions(-) diff --git a/src/ixdat/constants.py b/src/ixdat/constants.py index b38851d0..0feb7663 100644 --- a/src/ixdat/constants.py +++ b/src/ixdat/constants.py @@ -12,5 +12,37 @@ e0 = 1 / (u0 * c ** 2) # permittivity of free space / (C^2/(J*m)) R = NA * kB # gas constant / (J/(mol*K)) #NA in /mol -Faraday = NA * qe # Faraday's constant, C/mol amu = 1e-3 / NA # atomic mass unit / (kg) # amu=1g/NA #NA dimensionless +Far = NA * qe # Faraday's constant, C/mol + +FARADAY_CONSTANT = Far +AVOGADROS_CONSTANT = NA +BOLTZMAN_CONSTANT = kB + +STANDARD_TEMPERATURE = 298.15 # Standard temperature of 25 C in [K] +STANDARD_PRESSURE = 1e5 # Standard pressure of 1 bar in [Pa] + +DYNAMIC_VISCOSITIES = { + "O2": 2.07e-05, + "N2": 1.79e-05, + "Ar": 2.27e-05, + "He": 1.99e-05, + "CO": 1.78e-05, + "H2": 8.90e-06, +} # in [Pa*s] +MOLECULAR_DIAMETERS = { + "O2": 3.55e-10, + "N2": 3.7e-10, + "Ar": 3.58e-10, + "He": 2.15e-10, + "CO": 3.76e-10, + "H2": 2.71e-10, +} # in [m] +MOLAR_MASSES = { + "O2": 31.998, + "N2": 28.014, + "Ar": 39.948, + "He": 4.002, + "CO": 28.010, + "H2": 2.016, +} # in [g/mol] diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index 39fa9da7..61959ce1 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -1,8 +1,11 @@ """Module for representation and analysis of EC-MS measurements""" +import numpy as np +from ..constants import FARADAY_CONSTANT from .ec import ECMeasurement -from .ms import MSMeasurement +from .ms import MSMeasurement, MSCalResult from .cv import CyclicVoltammagram from ..exporters.ecms_exporter import ECMSExporter +from ..plotters.ms_plotter import STANDARD_COLORS class ECMSMeasurement(ECMeasurement, MSMeasurement): @@ -69,6 +72,70 @@ def as_cv(self): return ECMSCyclicVoltammogram.from_dict(self_as_dict) + def ecms_calibration_curve( + self, + mol, + mass, + n_el, + tspan_list=None, + tspan_bg=None, + ax="new", + axes_measurement=None, + ): + """Fit mol's sensitivity at mass based on steady periods of EC production + + Args: + mol (str): Name of the molecule to calibrate + mass (str): Name of the mass at which to calibrate + n_el (str): Number of electrons passed per molecule produced (remember the + sign! e.g. +4 for O2 by OER and -2 for H2 by HER) + tspan_list (list of tspan): THe timespans of steady electrolysis + tspan_bg (tspan): The time to use as a background + ax (Axis): The axis on which to plot the calibration curve result. Defaults + to a new axis. + axes_measurement (list of Axes): The EC-MS plot axes to highlight the + calibration on. Defaults to None. + + Return MSCalResult: The result of the calibration + """ + axis_ms = axes_measurement[0] if axes_measurement else None + axis_current = axes_measurement[0] if axes_measurement else None + Y_list = [] + n_list = [] + for tspan in tspan_list: + Y = self.integrate_signal(mass, tspan=tspan, tspan_bg=tspan_bg, ax=axis_ms) + # FIXME: plotting current by giving integrate() an axis doesn't work great. + I = self.integrate("raw current / [mA]", tspan=tspan) * 1e-3 + n = I / (n_el * FARADAY_CONSTANT) + Y_list.append(Y) + n_list.append(n) + n_vec = np.array(n_list) + Y_vec = np.array(Y_list) + pfit = np.polyfit(n_vec, Y_vec, deg=1) + F = pfit[0] + if ax: + color = STANDARD_COLORS[mass] + if ax == "new": + ax = self.plotter.new_ax() + ax.set_xlabel("amount produced / [nmol]") + ax.set_ylabel("integrated signal / [nC]") + ax.plot(n_vec * 1e9, Y_vec * 1e9, "o", color=color) + n_fit = np.array([0, max(n_vec)]) + Y_fit = n_fit * pfit[0] + pfit[1] + ax.plot(n_fit * 1e9, Y_fit * 1e9, "--", color=color) + cal = MSCalResult( + name=f"{mol}_{mass}", + mol=mol, + mass=mass, + cal_type="ecms_calibration_curve", + F=F, + ) + if ax: + if axes_measurement: + return cal, ax, axes_measurement + return cal, ax + return cal + class ECMSCyclicVoltammogram(CyclicVoltammagram, MSMeasurement): """Class for raw EC-MS functionality. Parents: CyclicVoltammogram, MSMeasurement @@ -122,3 +189,7 @@ def exporter(self): if not self._exporter: self._exporter = ECMSExporter(measurement=self) return self._exporter + + +class ECMSCalibration: + pass diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index 88b41144..ed494f4e 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -1,8 +1,17 @@ """Module for representation and analysis of MS measurements""" from ..measurements import Measurement -from ..plotters.ms_plotter import MSPlotter +from ..plotters.ms_plotter import MSPlotter, STANDARD_COLORS from ..exceptions import SeriesNotFoundError +from ..constants import ( + AVOGADROS_CONSTANT, + BOLTZMAN_CONSTANT, + STANDARD_TEMPERATURE, + STANDARD_PRESSURE, + DYNAMIC_VISCOSITIES, + MOLECULAR_DIAMETERS, + MOLAR_MASSES, +) import re import numpy as np @@ -112,6 +121,32 @@ def grab_cal_signal(self, signal_name, tspan=None, t_bg=None): return time, value * self.calibration[signal_name] + def integrate_signal(self, mass, tspan, tspan_bg, ax=None): + """Integrate a ms signal with background subtraction and evt. plotting + + TODO: Should this, like grab_signal does now, have the option of using a + background saved in the object rather than calculating a new one? + + Args: + mass (str): The mass for which to integrate the signal + tspan (tspan): The timespan over which to integrate + tspan_bg (tspan): Timespan at which the signal is at its background value + ax (Axis): axis to plot on. Defaults to None + """ + t, S = self.grab_signal(mass, tspan=tspan, include_endpoints=True) + if tspan_bg: + t_bg, S_bg_0 = self.grab_signal( + mass, tspan=tspan_bg, include_endpoints=True + ) + S_bg = np.mean(S_bg_0) * np.ones(t.shape) + else: + S_bg = np.zeros(t.shape) + if ax: + if ax == "new": + fig, ax = self.plotter.new_ax() + ax.fill_between(t, S_bg, S, color=STANDARD_COLORS[mass], alpha=0.2) + return np.trapz(S - S_bg, t) + @property def mass_list(self): """List of the masses for which ValueSeries are contained in the measurement""" @@ -139,3 +174,167 @@ def plotter(self): if not self._plotter: self._plotter = MSPlotter(measurement=self) return self._plotter + + +class MSCalResult: + """A class for a mass spec calibration result.""" + + def __init__( + self, + name=None, + mol=None, + mass=None, + cal_type=None, + F=None, + ): + self.name = name + self.mol = mol + self.mass = mass + self.cal_type = cal_type + self.F = F + + def __repr__(self): + return ( + f"{self.__class__.__name__}(name={self.name}, mol={self.mol}, " + f"mass={self.mass}, F={self.F})" + ) + + +class MSInlet: + """A class for describing the inlet to the mass spec + + Every MSInlet describes the rate and composition of the gas entering a mass + spectrometer. The default is a Spectro Inlets EC-MS chip. + """ + + def __init__( + self, + *, + l_cap=1e-3, + w_cap=6e-6, + h_cap=6e-6, + gas="He", + T=STANDARD_TEMPERATURE, + p=STANDARD_PRESSURE, + verbose=True, + ): + """Create a Chip object given its properties + + Args: + l_cap (float): capillary length [m]. Defaults to design parameter. + w_cap (float): capillary width [m]. Defaults to design parameter. + h_cap (float): capillary height [m]. Defaults to design parameter. + p (float): system pressure in [Pa] (if to change from that in medium) + T (float): system temperature in [K] (if to change from that in medium) + gas (str): the gas at the start of the inlet. + verbose (bool): whether to print stuff to the terminal + """ + self.verbose = verbose + self.l_cap = l_cap + self.w_cap = w_cap + self.h_cap = h_cap + self.p = p + self.T = T + self.gas = gas # TODO: Gas mixture class. This must be a pure gas now. + + def calc_n_dot_0( + self, gas=None, w_cap=None, h_cap=None, l_cap=None, T=None, p=None + ): + """Calculate the total molecular flux through the capillary in [s^-1] + + Uses Equation 4.10 of Daniel's Thesis. + + Args: + w_cap (float): capillary width [m], defaults to self.w_cap + h_cap (float): capillary height [m], defaults to self.h_cap + l_cap (float): capillary length [m], defaults to self.l_cap + gas (dict or str): the gas in the chip, defaults to self.gas + T (float): Temperature [K], if to be updated + p (float): pressure [Pa], if to be updated + Returns: + float: the total molecular flux in [s^-1] through the capillary + """ + + if w_cap is None: + w_cap = self.w_cap # capillary width in [m] + if h_cap is None: + h_cap = self.h_cap # capillary height in [m] + if l_cap is None: + l_cap = self.l_cap # effective capillary length in [m] + if T is None: + T = self.T + if p is None: + p = self.p + + pi = np.pi + eta = DYNAMIC_VISCOSITIES[gas] # dynamic viscosity in [Pa*s] + s = MOLECULAR_DIAMETERS[gas] # molecule diameter in [m] + m = MOLAR_MASSES[gas] * 1e-3 / AVOGADROS_CONSTANT # molecule mass in [kg] + + d = ((w_cap * h_cap) / pi) ** 0.5 * 2 + # d = 4.4e-6 #used in Henriksen2009 + a = d / 2 + p_1 = p + lambda_ = d # defining the transitional pressure + # ...from setting mean free path equal to capillary d + p_t = BOLTZMAN_CONSTANT * T / (2 ** 0.5 * pi * s ** 2 * lambda_) + p_2 = 0 + p_m = (p_1 + p_t) / 2 # average pressure in the transitional flow region + v_m = (8 * BOLTZMAN_CONSTANT * T / (pi * m)) ** 0.5 + # a reciprocal velocity used for short-hand: + nu = (m / (BOLTZMAN_CONSTANT * T)) ** 0.5 + + # ... and now, we're ready for the capillary equation. + # (need to turn of black and flake8 for tolerable format) + # fmt: off + # Equation 4.10 of Daniel Trimarco's PhD Thesis: + N_dot = ( # noqa + 1 / (BOLTZMAN_CONSTANT * T) * 1 / l_cap * ( # noqa + (p_t - p_2) * a**3 * 2 * pi / 3 * v_m + (p_1 - p_t) * ( # noqa + a**4 * pi / (8 * eta) * p_m + a**3 * 2 * pi / 3 * v_m * ( # noqa + (1 + 2 * a * nu * p_m / eta) / ( # noqa + 1 + 2.48 * a * nu * p_m / eta # noqa + ) # noqa + ) # noqa + ) # noqa + ) # noqa + ) # noqa + # fmt: on + n_dot = N_dot / AVOGADROS_CONSTANT + return n_dot + + def gas_flux_calibration( + self, + measurement, + mol, + mass, + tspan=None, + tspan_bg=None, + ax=None, + ): + """ + Args: + measurement (MSMeasurement): The measurement with the calibration data + mol (str): The name of the molecule to calibrate + mass (str): The mass to calibrate at + tspan (iter): The timespan to average the signal over. Defaults to all + tspan_bg (iter): Optional timespan at which the signal is at its background. + ax (matplotlib axis): the axis on which to indicate what signal is used + with a thicker line. Defaults to none + + Returns MSCalResult: a calibration result containing the sensitivity factor for + mol at mass + """ + t, S = measurement.grab_signal(mass, tspan=tspan, t_bg=tspan_bg) + if ax: + ax.plot(t, S, color=STANDARD_COLORS[mass], linewidth=5) + + n_dot = self.calc_n_dot_0(gas=mol) + F = np.mean(S) / n_dot + return MSCalResult( + name=f"{mol}_{mass}", + mol=mol, + mass=mass, + cal_type="gas_flux_calibration", + F=F, + ) From 2c66f6bcb2d913584207610a18b90efc5221c08b Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Thu, 15 Apr 2021 16:58:55 +0100 Subject: [PATCH 050/118] Saveable (actually, exportable) EC-MS calibration --- src/ixdat/techniques/ec_ms.py | 62 +++++++++++++++++++++++++++++++++-- src/ixdat/techniques/ms.py | 11 +++++-- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index 61959ce1..68dca7a0 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -6,6 +6,8 @@ from .cv import CyclicVoltammagram from ..exporters.ecms_exporter import ECMSExporter from ..plotters.ms_plotter import STANDARD_COLORS +from ..db import Saveable # FIXME: doesn't belong here. +import json # FIXME: doesn't belong here. class ECMSMeasurement(ECMeasurement, MSMeasurement): @@ -191,5 +193,61 @@ def exporter(self): return self._exporter -class ECMSCalibration: - pass +class ECMSCalibration(Saveable): + """Class for calibrations useful for ECMSMeasurements + + FIXME: A class in a technique module shouldn't inherit directly from Saveable. We + need to generalize calibration somehow + """ + column_attrs = {"name", "date", "setup", "ms_cal_results", "RE_vs_RHE", "A_el", "L"} + # FIXME: Not given a table_name as it can't save to the database without + # MSCalResult's being json-seriealizeable. Exporting and reading works, though :D + def __init__( + self, + name=None, + date=None, + setup=None, + ms_cal_results=None, + RE_vs_RHE=None, + A_el=None, + L=None, + ): + """ + Args: + name (str): Name of the calibration + date (str): Date of the calibration + setup (str): Name of the setup where the calibration is made + ms_cal_results (list of MSCalResult): The mass spec calibrations + RE_vs_RHE (float): the RE potential in [V] + A_el (float): the geometric electrode area in [cm^2] + L (float): the working distance in [m] + """ + super().__init__() + self.name = name or f"EC-MS calibration for {setup} on {date}" + self.date = date + self.setup = setup + self.ms_cal_results = ms_cal_results + self.RE_vs_RHE = RE_vs_RHE + self.A_el = A_el + self.L = L + + def export(self, path_to_file=None): + """Export an ECMSCalibration as a json-formatted text file""" + path_to_file = path_to_file or (self.name + ".ix") + self_as_dict = self.as_dict() + self_as_dict["ms_cal_results"] = [cal.as_dict() for cal in self.ms_cal_results] + with open(path_to_file, "w") as f: + json.dump(self_as_dict, f, indent=4) + + @classmethod + def read(cls, path_to_file): + """Read an ECMSCalibration from a json-formatted text file""" + with open(path_to_file) as f: + obj_as_dict = json.load(f) + obj_as_dict["ms_cal_results"] = [ + MSCalResult.from_dict(cal_as_dict) + for cal_as_dict in obj_as_dict["ms_cal_results"] + ] + return cls.from_dict(obj_as_dict) + + diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index ed494f4e..cbf49269 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -12,6 +12,7 @@ MOLECULAR_DIAMETERS, MOLAR_MASSES, ) +from ..db import Saveable import re import numpy as np @@ -176,8 +177,13 @@ def plotter(self): return self._plotter -class MSCalResult: - """A class for a mass spec calibration result.""" +class MSCalResult(Saveable): + """A class for a mass spec calibration result. + + TODO: How can we generalize calibration? I think that something inheriting directly + from saveable belongs in a top-level module and not in a technique module + """ + column_attrs = {"name", "mol", "mass", "cal_type", "F"} def __init__( self, @@ -187,6 +193,7 @@ def __init__( cal_type=None, F=None, ): + super().__init__() self.name = name self.mol = mol self.mass = mass From 931aaceb876eadc749ef9a1469c3bbdee3c11bbd Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Thu, 15 Apr 2021 22:41:50 +0100 Subject: [PATCH 051/118] full calibrated EC-MS plotting functionality --- src/ixdat/exceptions.py | 4 + src/ixdat/plotters/ecms_plotter.py | 60 ++++-- src/ixdat/plotters/ms_plotter.py | 333 ++++++++++++++++++++++------- src/ixdat/readers/ec_ms_pkl.py | 38 ++-- src/ixdat/techniques/ec_ms.py | 46 ++++ src/ixdat/techniques/ms.py | 50 ++++- 6 files changed, 407 insertions(+), 124 deletions(-) diff --git a/src/ixdat/exceptions.py b/src/ixdat/exceptions.py index 0b896b95..e12b6277 100644 --- a/src/ixdat/exceptions.py +++ b/src/ixdat/exceptions.py @@ -27,3 +27,7 @@ class ReadError(Exception): class TechniqueError(Exception): """ixdat errors having to do with techniques and their limitations""" + + +class QuantificationError(Exception): + """ixdat errors having to do with techniques and their limitations""" diff --git a/src/ixdat/plotters/ecms_plotter.py b/src/ixdat/plotters/ecms_plotter.py index ef88c6cb..699fd505 100644 --- a/src/ixdat/plotters/ecms_plotter.py +++ b/src/ixdat/plotters/ecms_plotter.py @@ -19,10 +19,12 @@ def plot_measurement( axes=None, mass_list=None, mass_lists=None, + mol_list=None, + mol_lists=None, tspan=None, tspan_bg=None, removebackground=None, - unit="A", + unit=None, V_str=None, J_str=None, V_color="k", @@ -53,6 +55,11 @@ def plot_measurement( mass_lists (list of list of str): Alternately, two lists can be given for masses in which case one list is plotted on the left y-axis and the other on the right y-axis of the top panel. + mol_list (list of str): The names of the molecules, eg. ["H2", ...] to + plot. Defaults to all of them (measurement.mass_list) + mol_lists (list of list of str): Alternately, two lists can be given for + molecules in which case one list is plotted on the left y-axis and the + other on the right y-axis of the top panel. tspan (iter of float): The time interval to plot, wrt measurement.tstamp tspan_bg (timespan): A timespan for which to assume the signal is at its background. The average signals during this timespan are subtracted. @@ -81,7 +88,9 @@ def plot_measurement( if not axes: axes = self.new_two_panel_axes( - n_bottom=2, n_top=(2 if mass_lists else 1), emphasis=emphasis + n_bottom=2, + n_top=(2 if (mass_lists or mol_lists) else 1), + emphasis=emphasis, ) if not tspan: @@ -103,29 +112,30 @@ def plot_measurement( J_color=J_color, **kwargs, ) - if mass_list or hasattr(measurement, "mass_list"): + if ( + mass_list + or mass_lists + or mol_list + or mol_lists + or hasattr(measurement, "mass_list") + ): # then we have MS data! - ms_axes = self.ms_plotter.plot_measurement( + self.ms_plotter.plot_measurement( measurement=measurement, ax=axes[0], - axes=[axes[0], axes[3]] if mass_lists else None, + axes=[axes[0], axes[3]] if (mass_lists or mol_lists) else axes[0], tspan=tspan, tspan_bg=tspan_bg, removebackground=removebackground, mass_list=mass_list, mass_lists=mass_lists, + mol_list=mol_list, + mol_lists=mol_lists, unit=unit, logplot=logplot, legend=legend, **kwargs, ) - try: - ms_axes.set_ylabel(f"signal / [{unit}]") - except AttributeError: - for ax in ms_axes: - ax.set_ylabel(f"signal / [{unit}]") - if ax not in axes: - axes.append(ax) axes[1].set_xlim(axes[0].get_xlim()) return axes @@ -136,10 +146,12 @@ def plot_vs_potential( axes=None, mass_list=None, mass_lists=None, + mol_list=None, + mol_lists=None, tspan=None, tspan_bg=None, removebackground=None, - unit="A", + unit=None, logplot=False, legend=True, emphasis="top", @@ -164,6 +176,11 @@ def plot_vs_potential( mass_lists (list of list of str): Alternately, two lists can be given for masses in which case one list is plotted on the left y-axis and the other on the right y-axis of the top panel. + mol_list (list of str): The names of the molecules, eg. ["H2", ...] to + plot. Defaults to all of them (measurement.mass_list) + mol_lists (list of list of str): Alternately, two lists can be given for + molecules in which case one list is plotted on the left y-axis and the + other on the right y-axis of the top panel. tspan (iter of float): The time interval to plot, wrt measurement.tstamp tspan_bg (timespan): A timespan for which to assume the signal is at its background. The average signals during this timespan are subtracted. @@ -182,33 +199,30 @@ def plot_vs_potential( if not axes: axes = self.new_two_panel_axes( - n_bottom=1, n_top=(2 if mass_lists else 1), emphasis=emphasis + n_bottom=2, + n_top=(2 if (mass_lists or mol_lists) else 1), + emphasis=emphasis, ) self.ec_plotter.plot_vs_potential( measurement=measurement, tspan=tspan, ax=axes[1], **kwargs ) - ms_axes = self.ms_plotter.plot_vs( + self.ms_plotter.plot_vs( x_name="potential", measurement=measurement, ax=axes[0], - axes=[axes[0], axes[2]] if mass_lists else None, + axes=[axes[0], axes[2]] if (mass_lists or mol_lists) else axes[0], tspan=tspan, tspan_bg=tspan_bg, removebackground=removebackground, mass_list=mass_list, mass_lists=mass_lists, + mol_list=mol_list, + mol_lists=mol_lists, unit=unit, logplot=logplot, legend=legend, **kwargs, ) - try: - ms_axes.set_ylabel(f"signal / [{unit}]") - except AttributeError: - for ax in ms_axes: - ax.set_ylabel(f"signal / [{unit}]") - if ax not in axes: - axes.append(ax) axes[1].set_xlim(axes[0].get_xlim()) return axes diff --git a/src/ixdat/plotters/ms_plotter.py b/src/ixdat/plotters/ms_plotter.py index 70d8cd1c..43dc72e9 100644 --- a/src/ixdat/plotters/ms_plotter.py +++ b/src/ixdat/plotters/ms_plotter.py @@ -17,16 +17,26 @@ def plot_measurement( axes=None, mass_list=None, mass_lists=None, + mol_list=None, + mol_lists=None, tspan=None, tspan_bg=None, removebackground=None, - unit="A", + unit=None, logplot=True, legend=True, **kwargs, ): """Plot m/z signal vs time (MID) data and return the axis. + There are four ways to specify what to plot. Only specify one of these: + mass_list: Uncalibrated signals in [(u/n/p)A] on on axis + mass_lists: Uncalibrated signals in [(u/n/p)A] on two axes + mol_list: Calibrated signals in [(u/n/p)mol/s] on on axis + mol_lists: Calibrated signals in [(u/n/p)mol/s] on two axes + Two axes refers to seperate left and right y-axes. Default is to use all + availeable masses as mass_list. + Args: measurement (MSMeasurement): defaults to the one that initiated the plotter ax (matplotlib axis): Defaults to a new axis @@ -36,6 +46,11 @@ def plot_measurement( mass_lists (list of list of str): Alternately, two lists can be given for masses in which case one list is plotted on the left y-axis and the other on the right y-axis of the top panel. + mol_list (list of str): The names of the molecules, eg. ["H2", ...] to + plot. Defaults to all of them (measurement.mass_list) + mol_lists (list of list of str): Alternately, two lists can be given for + molecules in which case one list is plotted on the left y-axis and the + other on the right y-axis of the top panel. tspan (iter of float): The time interval to plot, wrt measurement.tstamp tspan_bg (timespan): A timespan for which to assume the signal is at its background. The average signals during this timespan are subtracted. @@ -44,6 +59,7 @@ def plot_measurement( background subtraction. removebackground (bool): Whether otherwise to subtract pre-determined background signals if available. Defaults to (not logplot) + unit (str): defaults to "A" or "mol/s" logplot (bool): Whether to plot the MS data on a log scale (default True) legend (bool): Whether to use a legend for the MS data (default True) kwargs: extra key-word args are passed on to matplotlib's plot() @@ -51,59 +67,67 @@ def plot_measurement( measurement = measurement or self.measurement if removebackground is None: removebackground = not logplot - if not ax: - ax = ( - axes[0] - if axes - else self.new_ax(ylabel=f"signal / [{unit}]", xlabel="time / [s]") - ) - tspan_bg_right = None - if mass_lists: - axes = axes or [ax, ax.twinx()] - ax = axes[0] - mass_list = mass_lists[0] - try: - tspan_bg_right = tspan_bg[1] - if isinstance(tspan_bg_right, (float, int)): - raise TypeError - except (KeyError, TypeError): - tspan_bg_right = None - else: - tspan_bg = tspan_bg[0] - unit_factor = {"pA": 1e12, "nA": 1e9, "uA": 1e6, "A": 1}[unit] - # TODO: Real units with a unit module! This should even be able to figure out the - # unit prefix to put stuff in a nice 1-to-1e3 range - mass_list = mass_list or measurement.mass_list - for mass in mass_list: - t, v = measurement.grab_signal( - mass, - tspan=tspan, - t_bg=tspan_bg, - removebackground=removebackground, - include_endpoints=False, - ) + quantified, specs_this_axis, specs_next_axis = self._parse_overloaded_inputs( + mass_list, + mass_lists, + mol_list, + mol_lists, + unit, + tspan_bg, + ax, + axes, + measurement, + ) + ax = specs_this_axis["ax"] + v_list = specs_this_axis["v_list"] + tspan_bg = specs_this_axis["tspan_bg"] + unit = specs_this_axis["unit"] + unit_factor = specs_this_axis["unit_factor"] + for v_name in v_list: + if quantified: + t, v = measurement.grab_flux( + v_name, + tspan=tspan, + tspan_bg=tspan_bg, + removebackground=removebackground, + include_endpoints=False, + ) + else: + t, v = measurement.grab_signal( + v_name, + tspan=tspan, + t_bg=tspan_bg, + removebackground=removebackground, + include_endpoints=False, + ) if logplot: v[v < MIN_SIGNAL] = MIN_SIGNAL ax.plot( t, v * unit_factor, - color=STANDARD_COLORS.get(mass, "k"), - label=mass, + color=STANDARD_COLORS.get(v_name, "k"), + label=v_name, **kwargs, ) - if mass_lists: + ax.set_ylabel(f"signal / [{unit}]") + ax.set_xlabel("time / [s]") + if specs_next_axis: self.plot_measurement( measurement=measurement, - ax=axes[1], - mass_list=mass_lists[1], - unit=unit, + ax=specs_next_axis["ax"], + mass_list=specs_next_axis["mass_list"], + mol_list=specs_next_axis["mol_list"], + unit=specs_next_axis["unit"], tspan=tspan, - tspan_bg=tspan_bg_right, + tspan_bg=specs_next_axis["tspan_bg"], logplot=logplot, legend=legend, **kwargs, ) + axes = [ax, specs_next_axis["ax"]] + else: + axes = None if logplot: ax.set_yscale("log") @@ -121,16 +145,26 @@ def plot_vs( axes=None, mass_list=None, mass_lists=None, + mol_list=None, + mol_lists=None, tspan=None, tspan_bg=None, removebackground=None, - unit="A", + unit=None, logplot=True, legend=True, **kwargs, ): """Plot m/z signal (MID) data against a specified variable and return the axis. + There are four ways to specify what to plot. Only specify one of these: + mass_list: Uncalibrated signals in [(u/n/p)A] on on axis + mass_lists: Uncalibrated signals in [(u/n/p)A] on two axes + mol_list: Calibrated signals in [(u/n/p)mol/s] on on axis + mol_lists: Calibrated signals in [(u/n/p)mol/s] on two axes + Two axes refers to seperate left and right y-axes. Default is to use all + availeable masses as mass_list. + Args: x_name (str): Name of the variable to plot on the x-axis measurement (MSMeasurement): defaults to the one that initiated the plotter @@ -141,6 +175,11 @@ def plot_vs( mass_lists (list of list of str): Alternately, two lists can be given for masses in which case one list is plotted on the left y-axis and the other on the right y-axis of the top panel. + mol_list (list of str): The names of the molecules, eg. ["H2", ...] to + plot. Defaults to all of them (measurement.mass_list) + mol_lists (list of list of str): Alternately, two lists can be given for + molecules in which case one list is plotted on the left y-axis and the + other on the right y-axis of the top panel. tspan (iter of float): The time interval to plot, wrt measurement.tstamp tspan_bg (timespan): A timespan for which to assume the signal is at its background. The average signals during this timespan are subtracted. @@ -156,61 +195,72 @@ def plot_vs( measurement = measurement or self.measurement if removebackground is None: removebackground = not logplot - if not ax: - ax = ( - axes[0] - if axes - else self.new_ax(ylabel=f"signal / [{unit}]", xlabel=x_name) - ) - tspan_bg_right = None - if mass_lists: - axes = axes or [ax, ax.twinx()] - ax = axes[0] - mass_list = mass_lists[0] - try: - tspan_bg_right = tspan_bg[1] - if isinstance(tspan_bg_right, (float, int)): - raise TypeError - except (KeyError, TypeError): - tspan_bg_right = None - else: - tspan_bg = tspan_bg[0] - unit_factor = {"pA": 1e12, "nA": 1e9, "uA": 1e6, "A": 1}[unit] - # TODO: Real units with a unit module! This should even be able to figure out the - # unit prefix to put stuff in a nice 1-to-1e3 ranges + + # The overloaded inputs are a pain in the ass. This function helps: + quantified, specs_this_axis, specs_next_axis = self._parse_overloaded_inputs( + mass_list, + mass_lists, + mol_list, + mol_lists, + unit, + tspan_bg, + ax, + axes, + measurement, + ) + ax = specs_this_axis["ax"] + v_list = specs_this_axis["v_list"] + tspan_bg = specs_this_axis["tspan_bg"] + unit = specs_this_axis["unit"] + unit_factor = specs_this_axis["unit_factor"] + t, x = measurement.grab(x_name, tspan=tspan, include_endpoints=True) - mass_list = mass_list or measurement.mass_list - for mass in mass_list: - t_mass, v = measurement.grab_signal( - mass, - tspan=tspan, - t_bg=tspan_bg, - removebackground=removebackground, - include_endpoints=False, - ) + for v_name in v_list: + if quantified: + t_v, v = measurement.grab_flux( + v_name, + tspan=tspan, + tspan_bg=tspan_bg, + removebackground=removebackground, + include_endpoints=False, + ) + else: + t_v, v = measurement.grab_signal( + v_name, + tspan=tspan, + t_bg=tspan_bg, + removebackground=removebackground, + include_endpoints=False, + ) if logplot: v[v < MIN_SIGNAL] = MIN_SIGNAL - x_mass = np.interp(t_mass, t, x) + x_mass = np.interp(t_v, t, x) ax.plot( x_mass, v * unit_factor, - color=STANDARD_COLORS.get(mass, "k"), - label=mass, + color=STANDARD_COLORS.get(v_name, "k"), + label=v_name, **kwargs, ) - if mass_lists: + ax.set_ylabel(f"signal / [{unit}]") + ax.set_xlabel(x_name) + if specs_next_axis: self.plot_vs( x_name=x_name, measurement=measurement, - ax=axes[1], - mass_list=mass_lists[1], - unit=unit, + ax=specs_next_axis["ax"], + mass_list=specs_next_axis["mass_list"], + mol_list=specs_next_axis["mol_list"], + unit=specs_next_axis["unit"], tspan=tspan, - tspan_bg=tspan_bg_right, + tspan_bg=specs_next_axis["tspan_bg"], logplot=logplot, legend=legend, **kwargs, ) + axes = [ax, specs_next_axis["ax"]] + else: + axes = None if logplot: ax.set_yscale("log") @@ -219,11 +269,119 @@ def plot_vs( return axes if axes else ax + def _parse_overloaded_inputs( + self, + mass_list, + mass_lists, + mol_list, + mol_lists, + unit, + tspan_bg, + ax, + axes, + measurement, + ): + """From the overloaded function inputs, figure out what the user wants to do. + + This includes: + 1. determine if we're doing quantifed results (mols) or raw (masses) + 2. figure out if there's one or two axes (remaining) and what goes on them. + 3. figure out what to multiply numbers by when plotting to match the unit. + """ + # TODO: Maybe there's a way to do this function as a decorator? + # So this function is overloaded in the sense that the user can give + # exactly one of mol_list, mol_lists, mass_list, mass_lists. + # To manage that complexity, first we reduce it to two options, that down to + # either v_list or v_lists and a boolean "quantified": + quantified = False # default, if they give nothing + v_lists = None # default, if they give nothing + v_list = measurement.mass_list # default, if they give nothing + if mol_list: + quantified = True + v_list = mol_list + elif mol_lists: + quantified = True + v_lists = mol_lists + elif mass_list: + quantified = False + v_list = mol_list + elif mass_lists: + quantified = False + v_lists = mol_lists + + # as the next simplification, if they give two things (v_lists), we pretend we + # got one (v_list) but prepare an axis for a recursive call of this function. + if v_lists: + axes = axes or [ax, ax.twinx()] # prepare an axis unless we were given two. + ax_right = axes[-1] + ax = axes[0] + v_list = v_lists[0] + v_list_right = v_lists[1] + # ah, and to enable different background tspans for the two axes: + try: + tspan_bg_right = tspan_bg[1] + if isinstance(tspan_bg_right, (float, int)): + raise TypeError + except (KeyError, TypeError): + tspan_bg_right = None + else: + tspan_bg = tspan_bg[0] + if isinstance(unit, str) or not unit: + unit_right = unit + else: + unit_right = unit[1] + unit = unit[0] + specs_next_axis = { + "ax": ax_right, + "unit": unit_right, + "mass_list": None if quantified else v_list_right, + "mol_list": v_list_right if quantified else None, + "tspan_bg": tspan_bg_right, + } + else: + specs_next_axis = None + + if quantified: + unit = unit or "mol/s" + unit_factor = { + "pmol/s": 1e12, + "nmol/s": 1e9, + "umol/s": 1e6, + "mol/s": 1, # noqa + "pmol/s/cm^2": 1e12, + "nmol/s/cm^2": 1e9, + "umol/s/cm^2": 1e6, + "mol/s/cm^2": 1, # noqa + }[unit] + if "/cm^2" in unit: + unit_factor = unit_factor / measurement.A_el + else: + unit = unit or "A" + unit_factor = {"pA": 1e12, "nA": 1e9, "uA": 1e6, "A": 1}[unit] + # TODO: Real units with a unit module! This should even be able to figure out the + # unit prefix to put stuff in a nice 1-to-1e3 range + + if not ax: + ax = ( + axes[0] + if axes + else self.new_ax(ylabel=f"signal / [{unit}]", xlabel="time / [s]") + ) + specs_this_axis = { + "ax": ax, + "v_list": v_list, + "unit": unit, + "unit_factor": unit_factor, + "tspan_bg": tspan_bg, + } + + return quantified, specs_this_axis, specs_next_axis -MIN_SIGNAL = 1e-14 # So that the bottom half of the plot isn't wasted on log(noise) # ----- These are the standard colors for EC-MS plots! ------- # +MIN_SIGNAL = 1e-14 # So that the bottom half of the plot isn't wasted on log(noise) + STANDARD_COLORS = { "M2": "b", "M4": "m", @@ -260,4 +418,19 @@ def plot_vs( "M89": "darkmagenta", "M130": "purple", "M132": "purple", + # and now, molecules: + "H2": "b", + "He": "m", + "H2O": "y", + "CO": "0.5", + "N2": "0.5", + "O2": "k", + "Ar": "c", + "CO2": "brown", + "CH4": "r", + "C2H4": "g", + "O2_M34": "r", + "O2_M36": "g", + "CO2_M46": "purple", + "CO2_M48": "darkslategray", } diff --git a/src/ixdat/readers/ec_ms_pkl.py b/src/ixdat/readers/ec_ms_pkl.py index f22c89c4..ebc2fbee 100644 --- a/src/ixdat/readers/ec_ms_pkl.py +++ b/src/ixdat/readers/ec_ms_pkl.py @@ -82,27 +82,31 @@ def measurement_from_ec_ms_dataset( tseries_meas = Measurement("tseries_ms", technique="EC_MS", series_list=cols_list) for col in cols_str: - if col not in ec_ms_dict: + if col not in ec_ms_dict or col in tseries_meas.series_names: continue if col.endswith("-y"): + v_name = col[:-2] + tseries = tseries_meas[col[:-1] + "x"] unit_name = "A" if col.startswith("M") else "" - cols_list.append( - ValueSeries( - col[:-2], - unit_name=unit_name, - data=ec_ms_dict[col], - tseries=tseries_meas[col[:-1] + "x"], - ) - ) - if col in BIOLOGIC_COLUMN_NAMES and col not in tseries_meas.series_names: - cols_list.append( - ValueSeries( - name=col, - data=ec_ms_dict[col], - unit_name=get_column_unit(col), - tseries=tseries_meas["time/s"], - ) + elif col in BIOLOGIC_COLUMN_NAMES and col not in tseries_meas.series_names: + v_name = col + tseries = tseries_meas["time/s"] + unit_name = get_column_unit(col) + else: + print(f"Not including '{col}' as I don't know what it is.") + continue + data = ec_ms_dict[col] + if not tseries.data.size == data.size: + print(f"Not including '{col}' due to mismatch size with {tseries}") + continue + cols_list.append( + ValueSeries( + name=v_name, + data=data, + unit_name=unit_name, + tseries=tseries, ) + ) obj_as_dict = dict( name=name, diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index 68dca7a0..edd05a1e 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -46,6 +46,10 @@ def __init__(self, **kwargs): ms_kwargs.update(component_measurements=kwargs["component_measurements"]) ECMeasurement.__init__(self, **ec_kwargs) MSMeasurement.__init__(self, **ms_kwargs) + self.calibration = kwargs.get("calibration", None) + if self.calibration: + self.normalize_current(A_el=self.calibration.A_el) + self.calibrate_RE(RE_vs_RHE=self.calibration.RE_vs_RHE) @property def plotter(self): @@ -64,6 +68,15 @@ def exporter(self): self._exporter = ECMSExporter(measurement=self) return self._exporter + def as_dict(self): + self_as_dict = super().as_dict() + + if self.calibration: + self_as_dict["calibration"] = self.calibration + # FIXME: necessary because an ECMSCalibration is not serializeable + # If it it was it would go into extra_column_attrs + return self_as_dict + def as_cv(self): self_as_dict = self.as_dict() @@ -174,6 +187,8 @@ def __init__(self, **kwargs): ms_kwargs.update(series_list=kwargs["series_list"]) MSMeasurement.__init__(self, **ms_kwargs) self.plot = self.plotter.plot_vs_potential + # FIXME: only necessary because an ECMSCalibration is not seriealizeable. + self.calibration = kwargs.get("calibration", None) @property def plotter(self): @@ -192,6 +207,15 @@ def exporter(self): self._exporter = ECMSExporter(measurement=self) return self._exporter + def as_dict(self): + self_as_dict = super().as_dict() + + if self.calibration: + self_as_dict["calibration"] = self.calibration + # FIXME: necessary because an ECMSCalibration is not serializeable + # If it it was it would go into extra_column_attrs + return self_as_dict + class ECMSCalibration(Saveable): """Class for calibrations useful for ECMSMeasurements @@ -199,6 +223,7 @@ class ECMSCalibration(Saveable): FIXME: A class in a technique module shouldn't inherit directly from Saveable. We need to generalize calibration somehow """ + column_attrs = {"name", "date", "setup", "ms_cal_results", "RE_vs_RHE", "A_el", "L"} # FIXME: Not given a table_name as it can't save to the database without # MSCalResult's being json-seriealizeable. Exporting and reading works, though :D @@ -250,4 +275,25 @@ def read(cls, path_to_file): ] return cls.from_dict(obj_as_dict) + @property + def mol_list(self): + return list({cal.mol for cal in self.ms_cal_results}) + + @property + def name_list(self): + return list({cal.name for cal in self.ms_cal_results}) + + def __contains__(self, mol): + return mol in self.mol_list or mol in self.name_list + + def __iter__(self): + yield from self.ms_cal_results + + def get_mass_and_F(self, mol): + """Return the mass and sensitivity factor to use for simple quant. of mol""" + cal_list_for_mol = [cal for cal in self if cal.mol == mol or cal.name == mol] + Fs = [cal.F for cal in cal_list_for_mol] + index = np.argmax(np.array(Fs)) + the_good_cal = cal_list_for_mol[index] + return the_good_cal.mass, the_good_cal.F diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index cbf49269..d5019d10 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -2,7 +2,7 @@ from ..measurements import Measurement from ..plotters.ms_plotter import MSPlotter, STANDARD_COLORS -from ..exceptions import SeriesNotFoundError +from ..exceptions import SeriesNotFoundError, QuantificationError from ..constants import ( AVOGADROS_CONSTANT, BOLTZMAN_CONSTANT, @@ -28,7 +28,13 @@ class MSMeasurement(Measurement): } def __init__( - self, name, mass_aliases=None, signal_bgs=None, tspan_bg=None, **kwargs + self, + name, + mass_aliases=None, + signal_bgs=None, + tspan_bg=None, + calibration=None, + **kwargs, ): """Initializes a MS Measurement @@ -40,6 +46,7 @@ def __init__( do not have the standard 'M' format used by ixdat. signal_bgs (dict): {mass: S_bg} where S_bg is the background signal in [A] for the mass (typically set with a timespan by `set_bg()`) + calibration (ECMSCalibration): A calibration for the MS signals tspan_bg (timespan): background time used to set masses """ super().__init__(name, **kwargs) @@ -81,7 +88,7 @@ def grab_signal( removebackground=False, include_endpoints=False, ): - """Returns raw signal for a given signal name + """Returns t, S where S is raw signal in [A] for a given signal name (ie mass) Args: signal_name (str): Name of the signal. @@ -89,6 +96,7 @@ def grab_signal( t_bg (list): Timespan that corresponds to the background signal. If not given, no background is subtracted. removebackground (bool): Whether to remove a pre-set background if available + include_endpoints (bool): Whether to ensure tspan[0] and tspan[-1] are in t """ time, value = self.grab( signal_name, tspan=tspan, include_endpoints=include_endpoints @@ -113,7 +121,8 @@ def grab_cal_signal(self, signal_name, tspan=None, t_bg=None): t_bg (list): Timespan that corresponds to the background signal. If not given, no background is subtracted. """ - # TODO: Not final implementation + # TODO: Not final implementation. + # FIXME: Depreciated! Use grab_flux instead! if self.calibration is None: print("No calibration dict found.") return @@ -122,6 +131,38 @@ def grab_cal_signal(self, signal_name, tspan=None, t_bg=None): return time, value * self.calibration[signal_name] + def grab_flux( + self, + mol, + tspan=None, + tspan_bg=None, + removebackground=False, + include_endpoints=False, + ): + """Return the flux of mol (calibrated signal) in [mol/s] + + Args: + mol (str): Name of the molecule. + tspan (list): Timespan for which the signal is returned. + t_bg (list): Timespan that corresponds to the background signal. + If not given, no background is subtracted. + removebackground (bool): Whether to remove a pre-set background if available + """ + if not self.calibration or mol not in self.calibration: + raise QuantificationError( + f"Can't quantify {mol} in {self}: Not in calibration={self.calibration}" + ) + mass, F = self.calibration.get_mass_and_F(mol) + x, y = self.grab_signal( + mass, + tspan=tspan, + t_bg=tspan_bg, + removebackground=removebackground, + include_endpoints=include_endpoints, + ) + n_dot = y / F + return x, n_dot + def integrate_signal(self, mass, tspan, tspan_bg, ax=None): """Integrate a ms signal with background subtraction and evt. plotting @@ -183,6 +224,7 @@ class MSCalResult(Saveable): TODO: How can we generalize calibration? I think that something inheriting directly from saveable belongs in a top-level module and not in a technique module """ + column_attrs = {"name", "mol", "mass", "cal_type", "F"} def __init__( From 2cc5f2c95bf83c5124bb16981721292f05723cb4 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Thu, 15 Apr 2021 23:00:41 +0100 Subject: [PATCH 052/118] v0.1.0 --- README.rst | 27 +++++++++++++++++++++------ src/ixdat/__init__.py | 2 +- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index 61269c95..6f90a5a0 100644 --- a/README.rst +++ b/README.rst @@ -2,15 +2,30 @@ ``ixdat``: The In-situ Experimental Data Tool ============================================= -``ixdat`` will provide a powerful **object-oriented** interface to experimental data, especially in-situ experimental data for which it is of interest to combine data obtained simultaneously from multiple techniques. +``ixdat`` provides a powerful **object-oriented** interface to experimental data, especially in-situ experimental data for which it is of interest to combine data obtained simultaneously from multiple techniques. -``ixdat`` will replace the existing electrochemistry - mass spectrometry data tool, `EC_MS `_, and will thus become a powerful stand-alone tool for analysis and visualization of data acquired by the equipment of `Spectro Inlets `_ and other EC-MS solutions. +Documentation is at https://ixdat.readthedocs.io + +``ixdat`` has replaced the existing electrochemistry - mass spectrometry data tool, `EC_MS `_, +and will thus become a powerful stand-alone tool for analysis and visualization of data acquired by the equipment of `Spectro Inlets `_ and other EC-MS solutions. It will also replace the existing electrochemistry - synchrotron GIXRD data tool, `EC_Xray `_ when needed. -Over time, it will acquire functionality for more and more techniques. +Over time, it will acquire functionality for more and more techniques. Please help get yours incorporated! + +In addition to a **pluggable** parser interface for importing your data format, ``ixdat`` it also includes +pluggable exporters and plotters, as well as a database interface. A relational model of experimental data is +thought into every level. + +``ixdat`` is shown in practice in a growing number of open repositories of data and analysis +for academic publications: + +- Tracking oxygen atoms in electrochemical CO oxidation - Part II: Lattice oxygen reactivity in oxides of Pt and Ir -In addition to a **pluggable** parser interface for importing your data format, it will include pluggable exporters and plotters, as well as a database interface. + - Article: https://doi.org/10.1016/j.electacta.2021.137844 + - Repository: https://github.com/ScottSoren/pyCOox_public -We will update this README as features are added. More importantly, we will document this project as we develop it here: https://ixdat.readthedocs.io/ +- Dynamic Interfacial Reaction Rates from Electrochemistry - Mass Spectrometry -``ixdat`` is free and open source software and we welcome input and new collaborators. + - Article: + - Repository: https://github.com/kkrempl/Dynamic-Interfacial-Reaction-Rates +`ixdat`` is free and open source software and we welcome input and new collaborators. Please help us improve! diff --git a/src/ixdat/__init__.py b/src/ixdat/__init__.py index d605a4a8..82de45fb 100644 --- a/src/ixdat/__init__.py +++ b/src/ixdat/__init__.py @@ -1,6 +1,6 @@ """initialize ixdat, giving top-level access to a few of the important structures """ -__version__ = "0.0.10" +__version__ = "0.1.0" __title__ = "ixdat" __description__ = "The in-situ experimental data tool" __url__ = "https://github.com/ixdat/ixdat" From a53565690a0aafbc2f5ea614cdf76ccc24fb664c Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Wed, 21 Apr 2021 11:43:57 +0100 Subject: [PATCH 053/118] minor debug of ecms plotting, write grab_flux_for_t --- src/ixdat/measurements.py | 2 +- src/ixdat/plotters/ms_plotter.py | 6 ++++-- src/ixdat/techniques/ms.py | 30 ++++++++++++++++++++++++++++-- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index 7763bec0..f37e355d 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -474,7 +474,7 @@ def grab(self, item, tspan=None, include_endpoints=False): tseries = vseries.tseries v = vseries.data t = tseries.data + tseries.tstamp - self.tstamp - if tspan: + if tspan is not None: # np arrays don't boolean well :( if include_endpoints: if t[0] < tspan[0]: # then add a point to include tspan[0] v_0 = np.interp(tspan[0], t, v) diff --git a/src/ixdat/plotters/ms_plotter.py b/src/ixdat/plotters/ms_plotter.py index 43dc72e9..bcef2d70 100644 --- a/src/ixdat/plotters/ms_plotter.py +++ b/src/ixdat/plotters/ms_plotter.py @@ -304,10 +304,10 @@ def _parse_overloaded_inputs( v_lists = mol_lists elif mass_list: quantified = False - v_list = mol_list + v_list = mass_list elif mass_lists: quantified = False - v_lists = mol_lists + v_lists = mass_lists # as the next simplification, if they give two things (v_lists), we pretend we # got one (v_list) but prepare an axis for a recursive call of this function. @@ -429,8 +429,10 @@ def _parse_overloaded_inputs( "CO2": "brown", "CH4": "r", "C2H4": "g", + "O2_M32": "k", "O2_M34": "r", "O2_M36": "g", + "CO2_M44": "brown", "CO2_M46": "purple", "CO2_M48": "darkslategray", } diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index d5019d10..bb86d740 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -50,7 +50,7 @@ def __init__( tspan_bg (timespan): background time used to set masses """ super().__init__(name, **kwargs) - self.calibration = None # TODO: Not final implementation + self.calibration = calibration # TODO: Not final implementation self.mass_aliases = mass_aliases or {} self.signal_bgs = signal_bgs or {} self.tspan_bg = tspan_bg @@ -144,7 +144,7 @@ def grab_flux( Args: mol (str): Name of the molecule. tspan (list): Timespan for which the signal is returned. - t_bg (list): Timespan that corresponds to the background signal. + tspan_bg (list): Timespan that corresponds to the background signal. If not given, no background is subtracted. removebackground (bool): Whether to remove a pre-set background if available """ @@ -163,6 +163,32 @@ def grab_flux( n_dot = y / F return x, n_dot + def grab_flux_for_t( + self, + mol, + t, + tspan_bg=None, + removebackground=False, + include_endpoints=False, + ): + """Return the flux of mol (calibrated signal) in [mol/s] for a given time vec + + Args: + mol (str): Name of the molecule. + t (np.array): The time vector along which to give the flux + tspan_bg (tspan): Timespan that corresponds to the background signal. + If not given, no background is subtracted. + removebackground (bool): Whether to remove a pre-set background if available + """ + t_0, y_0 = self.grab_flux( + mol, + tspan_bg=tspan_bg, + removebackground=removebackground, + include_endpoints=include_endpoints, + ) + y = np.interp(t, t_0, y_0) + return y + def integrate_signal(self, mass, tspan, tspan_bg, ax=None): """Integrate a ms signal with background subtraction and evt. plotting From b60071b947e2e379eb963d57f746c8d48d877501 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Wed, 21 Apr 2021 12:49:32 +0100 Subject: [PATCH 054/118] more (messy) calibration integration, v0.1.1 --- src/ixdat/__init__.py | 2 +- src/ixdat/measurements.py | 10 +-- src/ixdat/techniques/ec.py | 36 ++++++++- src/ixdat/techniques/ec_ms.py | 134 +++++++++++++++++++++++++++++----- 4 files changed, 154 insertions(+), 28 deletions(-) diff --git a/src/ixdat/__init__.py b/src/ixdat/__init__.py index 82de45fb..2c390e47 100644 --- a/src/ixdat/__init__.py +++ b/src/ixdat/__init__.py @@ -1,6 +1,6 @@ """initialize ixdat, giving top-level access to a few of the important structures """ -__version__ = "0.1.0" +__version__ = "0.1.1" __title__ = "ixdat" __description__ = "The in-situ experimental data tool" __url__ = "https://github.com/ixdat/ixdat" diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index f37e355d..4aa876ee 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -143,11 +143,11 @@ def from_dict(cls, obj_as_dict): else: # Normally, we're going to want to make sure that we're in technique_class = cls - try: - measurement = technique_class(**obj_as_dict) - except Exception: - raise - return measurement + + if technique_class is cls: + return cls(**obj_as_dict) + else: # Then its from_dict() might have more than ours: + return technique_class.from_dict(obj_as_dict) @classmethod def read(cls, path_to_file, reader, **kwargs): diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index f8c14d12..8bcc6835 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -161,6 +161,7 @@ def __init__( 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, @@ -175,17 +176,20 @@ def __init__( 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.RE_vs_RHE = RE_vs_RHE self.R_Ohm = R_Ohm self.raw_potential_names = raw_potential_names self.I_str = I_str self.J_str = J_str - self.A_el = A_el self.raw_current_names = raw_current_names self.cycle_names = cycle_names @@ -232,6 +236,22 @@ def __init__( ) 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 @@ -438,7 +458,7 @@ def potential(self): fixed_V_str = raw_potential.name fixed_potential_data = raw_potential.data fixed_unit_name = raw_potential.unit_name - if self.RE_vs_RHE: + 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 " @@ -619,3 +639,11 @@ def as_cv(self): del self_as_dict["s_ids"] # Note, this works perfectly! All needed information is in self_as_dict :) return CyclicVoltammagram.from_dict(self_as_dict) + + +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 diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index edd05a1e..f04abe07 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -29,6 +29,12 @@ class ECMSMeasurement(ECMeasurement, MSMeasurement): } def __init__(self, **kwargs): + if "calibration" in kwargs and kwargs["calibration"]: + self.calibration = kwargs["calibration"] + else: + # FIXME: This is a slight mess. + # ECMeasurement should also have RE_vs_RHE and A_el in a calibration + self.calibration = ECMSCalibration() """FIXME: Passing the right key-word arguments on is a mess""" ec_kwargs = { k: v for k, v in kwargs.items() if k in ECMeasurement.get_all_column_attrs() @@ -36,6 +42,7 @@ def __init__(self, **kwargs): ms_kwargs = { k: v for k, v in kwargs.items() if k in MSMeasurement.get_all_column_attrs() } + ms_kwargs["calibration"] = self.calibration # FIXME: This is a mess. # FIXME: I think the lines below could be avoided with a PlaceHolderObject that # works together with MemoryBackend if "series_list" in kwargs: @@ -46,10 +53,6 @@ def __init__(self, **kwargs): ms_kwargs.update(component_measurements=kwargs["component_measurements"]) ECMeasurement.__init__(self, **ec_kwargs) MSMeasurement.__init__(self, **ms_kwargs) - self.calibration = kwargs.get("calibration", None) - if self.calibration: - self.normalize_current(A_el=self.calibration.A_el) - self.calibrate_RE(RE_vs_RHE=self.calibration.RE_vs_RHE) @property def plotter(self): @@ -72,11 +75,23 @@ def as_dict(self): self_as_dict = super().as_dict() if self.calibration: - self_as_dict["calibration"] = self.calibration + self_as_dict["calibration"] = self.calibration.as_dict() # FIXME: necessary because an ECMSCalibration is not serializeable # If it it was it would go into extra_column_attrs return self_as_dict + @classmethod + def from_dict(cls, obj_as_dict): + """Unpack the ECMSCalibration when initiating from a dict""" + if "calibration" in obj_as_dict: + if isinstance(obj_as_dict["calibration"], dict): + # FIXME: This is a mess + obj_as_dict["calibration"] = ECMSCalibration.from_dict( + obj_as_dict["calibration"] + ) + obj = super(ECMSMeasurement, cls).from_dict(obj_as_dict) + return obj + def as_cv(self): self_as_dict = self.as_dict() @@ -87,6 +102,32 @@ def as_cv(self): return ECMSCyclicVoltammogram.from_dict(self_as_dict) + def ecms_calibration(self, mol, mass, n_el, tspan, tspan_bg=None): + """Calibrate for mol and mass based on one period of steady electrolysis + + Args: + mol (str): Name of the molecule to calibrate + mass (str): Name of the mass at which to calibrate + n_el (str): Number of electrons passed per molecule produced (remember the + sign! e.g. +4 for O2 by OER and -2 for H2 by HER) + tspan (tspan): The timespan of steady electrolysis + tspan_bg (tspan): The time to use as a background + + Return MSCalResult: The result of the calibration + """ + Y = self.integrate_signal(mass, tspan=tspan, tspan_bg=tspan_bg) + Q = self.integrate("raw current / [mA]", tspan=tspan) * 1e-3 + n = Q / (n_el * FARADAY_CONSTANT) + F = Y / n + cal = MSCalResult( + name=f"{mol}_{mass}", + mol=mol, + mass=mass, + cal_type="ecms_calibration", + F=F, + ) + return cal + def ecms_calibration_curve( self, mol, @@ -111,7 +152,8 @@ def ecms_calibration_curve( axes_measurement (list of Axes): The EC-MS plot axes to highlight the calibration on. Defaults to None. - Return MSCalResult: The result of the calibration + Return MSCalResult(, Axis(, Axis)): The result of the calibration + (and requested axes) """ axis_ms = axes_measurement[0] if axes_measurement else None axis_current = axes_measurement[0] if axes_measurement else None @@ -120,8 +162,8 @@ def ecms_calibration_curve( for tspan in tspan_list: Y = self.integrate_signal(mass, tspan=tspan, tspan_bg=tspan_bg, ax=axis_ms) # FIXME: plotting current by giving integrate() an axis doesn't work great. - I = self.integrate("raw current / [mA]", tspan=tspan) * 1e-3 - n = I / (n_el * FARADAY_CONSTANT) + Q = self.integrate("raw current / [mA]", tspan=tspan) * 1e-3 + n = Q / (n_el * FARADAY_CONSTANT) Y_list.append(Y) n_list.append(n) n_vec = np.array(n_list) @@ -211,17 +253,30 @@ def as_dict(self): self_as_dict = super().as_dict() if self.calibration: - self_as_dict["calibration"] = self.calibration - # FIXME: necessary because an ECMSCalibration is not serializeable - # If it it was it would go into extra_column_attrs + self_as_dict["calibration"] = self.calibration.as_dict() + # FIXME: now that ECMSCalibration should be seriealizeable, it could + # go into extra_column_attrs. But it should be a reference. return self_as_dict + @classmethod + def from_dict(cls, obj_as_dict): + """Unpack the ECMSCalibration when initiating from a dict""" + if "calibration" in obj_as_dict: + if isinstance(obj_as_dict["calibration"], dict): + # FIXME: This is a mess + obj_as_dict["calibration"] = ECMSCalibration.from_dict( + obj_as_dict["calibration"] + ) + obj = super(ECMSCyclicVoltammogram, cls).from_dict(obj_as_dict) + return obj + class ECMSCalibration(Saveable): """Class for calibrations useful for ECMSMeasurements FIXME: A class in a technique module shouldn't inherit directly from Saveable. We - need to generalize calibration somehow + need to generalize calibration somehow. + Also, ECMSCalibration should inherit from or otherwise use a class MSCalibration """ column_attrs = {"name", "date", "setup", "ms_cal_results", "RE_vs_RHE", "A_el", "L"} @@ -251,16 +306,30 @@ def __init__( self.name = name or f"EC-MS calibration for {setup} on {date}" self.date = date self.setup = setup - self.ms_cal_results = ms_cal_results + self.ms_cal_results = ms_cal_results or [] self.RE_vs_RHE = RE_vs_RHE self.A_el = A_el self.L = L + def as_dict(self): + """Have to dict the MSCalResults to get serializable as_dict (see Saveable)""" + self_as_dict = super().as_dict() + self_as_dict["ms_cal_results"] = [cal.as_dict() for cal in self.ms_cal_results] + return self_as_dict + + @classmethod + def from_dict(cls, obj_as_dict): + """Unpack the MSCalResults when initiating from a dict""" + obj = super(ECMSCalibration, cls).from_dict(obj_as_dict) + obj.ms_cal_results = [ + MSCalResult.from_dict(cal_as_dict) for cal_as_dict in obj.ms_cal_results + ] + return obj + def export(self, path_to_file=None): """Export an ECMSCalibration as a json-formatted text file""" path_to_file = path_to_file or (self.name + ".ix") self_as_dict = self.as_dict() - self_as_dict["ms_cal_results"] = [cal.as_dict() for cal in self.ms_cal_results] with open(path_to_file, "w") as f: json.dump(self_as_dict, f, indent=4) @@ -269,16 +338,16 @@ def read(cls, path_to_file): """Read an ECMSCalibration from a json-formatted text file""" with open(path_to_file) as f: obj_as_dict = json.load(f) - obj_as_dict["ms_cal_results"] = [ - MSCalResult.from_dict(cal_as_dict) - for cal_as_dict in obj_as_dict["ms_cal_results"] - ] return cls.from_dict(obj_as_dict) @property def mol_list(self): return list({cal.mol for cal in self.ms_cal_results}) + @property + def mass_list(self): + return list({cal.mass for cal in self.ms_cal_results}) + @property def name_list(self): return list({cal.name for cal in self.ms_cal_results}) @@ -297,3 +366,32 @@ def get_mass_and_F(self, mol): the_good_cal = cal_list_for_mol[index] return the_good_cal.mass, the_good_cal.F + + def get_F(self, mol, mass): + """Return the sensitivity factor for mol at mass""" + cal_list_for_mol_at_mass = [ + cal + for cal in self + if (cal.mol == mol or cal.name == mol) and cal.mass == mass + ] + F_list = [cal.F for cal in cal_list_for_mol_at_mass] + return np.mean(np.array(F_list)) + + def scaled_to(self, ms_cal_result): + """Return a new calibration w scaled sensitivity factors to match one given""" + F_0 = self.get_F(ms_cal_result.mol, ms_cal_result.mass) + scale_factor = ms_cal_result.F / F_0 + calibration_as_dict = self.as_dict() + new_cal_list = [] + for cal in self.ms_cal_results: + cal = MSCalResult( + name=cal.name, + mass=cal.mass, + mol=cal.mol, + F=cal.F * scale_factor, + cal_type=cal.cal_type + " scaled", + ) + new_cal_list.append(cal) + calibration_as_dict["ms_cal_results"] = [cal.as_dict() for cal in new_cal_list] + calibration_as_dict["name"] = calibration_as_dict["name"] + " scaled" + return self.__class__.from_dict(calibration_as_dict) From c1cc6dc85446174a553b92f9caacfab6c9837350 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Wed, 28 Apr 2021 17:56:16 +0100 Subject: [PATCH 055/118] import .mpt files with sub-second timestamps --- src/ixdat/__init__.py | 2 +- src/ixdat/readers/biologic.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ixdat/__init__.py b/src/ixdat/__init__.py index 2c390e47..14b606ba 100644 --- a/src/ixdat/__init__.py +++ b/src/ixdat/__init__.py @@ -1,6 +1,6 @@ """initialize ixdat, giving top-level access to a few of the important structures """ -__version__ = "0.1.1" +__version__ = "0.1.2" __title__ = "ixdat" __description__ = "The in-situ experimental data tool" __url__ = "https://github.com/ixdat/ixdat" diff --git a/src/ixdat/readers/biologic.py b/src/ixdat/readers/biologic.py index badd4b3a..3e6d079f 100644 --- a/src/ixdat/readers/biologic.py +++ b/src/ixdat/readers/biologic.py @@ -235,9 +235,12 @@ def get_column_unit(column_name): return unit_name +# Formats by which timestamps are saved in various EC-Labs # with example encountered BIOLOGIC_TIMESTAMP_FORMS = ( - "%m/%d/%Y %H:%M:%S", # like 07/29/2020 10:31:03 "%m-%d-%Y %H:%M:%S", # like 01-31-2020 10:32:02 + "%m/%d/%Y %H:%M:%S", # like 07/29/2020 10:31:03 + "%m-%d-%Y %H:%M:%S.%f", # (not seen yet) + "%m/%d/%Y %H:%M:%S.%f", # like 04/27/2021 11:35:39.227 (EC-Lab v11.34) ) # This tuple contains variable names encountered in .mpt files. The tuple can be used by From 8f5892543b23fff10cd3c682962dfbd822feca3a Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Thu, 29 Apr 2021 14:33:24 +0100 Subject: [PATCH 056/118] write Spectrum class and zilien spectrum reader --- .../test_zilien_spectrum_reader.py | 14 ++ src/ixdat/__init__.py | 3 +- src/ixdat/measurements.py | 12 +- src/ixdat/plotters/spectrum_plotter.py | 15 ++ src/ixdat/readers/__init__.py | 3 +- src/ixdat/readers/reading_tools.py | 1 + src/ixdat/readers/zilien.py | 52 +++++- src/ixdat/spectra.py | 157 ++++++++++++++++++ src/ixdat/techniques/ms.py | 9 + 9 files changed, 255 insertions(+), 11 deletions(-) create mode 100644 development_scripts/reader_testers/test_zilien_spectrum_reader.py create mode 100644 src/ixdat/plotters/spectrum_plotter.py create mode 100644 src/ixdat/spectra.py diff --git a/development_scripts/reader_testers/test_zilien_spectrum_reader.py b/development_scripts/reader_testers/test_zilien_spectrum_reader.py new file mode 100644 index 00000000..1d4de58e --- /dev/null +++ b/development_scripts/reader_testers/test_zilien_spectrum_reader.py @@ -0,0 +1,14 @@ +from pathlib import Path +from ixdat import Spectrum + +path_to_file = ( + Path(r"C:\Users\scott\Dropbox\ixdat_resources\test_data\zilien_spectra") + / "mass scan started at measurement time 0001700.tsv" +) + +spec = Spectrum.read( + path_to_file, + reader="zilien_spec", +) + +spec.plot() diff --git a/src/ixdat/__init__.py b/src/ixdat/__init__.py index 14b606ba..2b2e2239 100644 --- a/src/ixdat/__init__.py +++ b/src/ixdat/__init__.py @@ -1,6 +1,6 @@ """initialize ixdat, giving top-level access to a few of the important structures """ -__version__ = "0.1.2" +__version__ = "0.1.3" __title__ = "ixdat" __description__ = "The in-situ experimental data tool" __url__ = "https://github.com/ixdat/ixdat" @@ -10,6 +10,7 @@ __license__ = "MIT" from .measurements import Measurement +from .spectra import Spectrum from . import db from . import techniques from . import plotters diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index 4aa876ee..e9da13f1 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -10,9 +10,9 @@ import numpy as np from .db import Saveable, PlaceHolderObject from .data_series import DataSeries, TimeSeries, ValueSeries -from ixdat.projects.samples import Sample -from ixdat.projects.lablogs import LabLog -from ixdat.exporters.csv_exporter import CSVExporter +from .projects.samples import Sample +from .projects.lablogs import LabLog +from .exporters.csv_exporter import CSVExporter from .exceptions import BuildError, SeriesNotFoundError # , TechniqueError @@ -523,11 +523,9 @@ def plotter(self): if not self._plotter: from .plotters import ValuePlotter + # FIXME: I had to import here to avoid running into circular import issues + self._plotter = ValuePlotter(measurement=self) - # self.plot_measurement.__doc__ = self._plotter.plot_measurement.__doc__ - # self.plot.__doc__ = self._plotter.plot_measurement.__doc__ - # FIXME: Help! plot_measurement() needs to be wrapped with the plotter's - # plot_measu return self._plotter @property diff --git a/src/ixdat/plotters/spectrum_plotter.py b/src/ixdat/plotters/spectrum_plotter.py new file mode 100644 index 00000000..92c4b3e7 --- /dev/null +++ b/src/ixdat/plotters/spectrum_plotter.py @@ -0,0 +1,15 @@ +from .base_mpl_plotter import MPLPlotter + + +class SpectrumPlotter(MPLPlotter): + def __init__(self, spectrum=None): + self.spectrum = spectrum + + def plot(self, spectrum=None, ax=None, **kwargs): + spectrum = spectrum or self.spectrum + if not ax: + ax = self.new_ax() + ax.plot(spectrum.x, spectrum.y, **kwargs) + ax.set_xlabel(spectrum.x_name) + ax.set_ylabel(spectrum.y_name) + return ax diff --git a/src/ixdat/readers/__init__.py b/src/ixdat/readers/__init__.py index 669ed9fa..b7d4a26e 100644 --- a/src/ixdat/readers/__init__.py +++ b/src/ixdat/readers/__init__.py @@ -20,7 +20,7 @@ from .cinfdata import CinfdataTXTReader # ec-ms -from .zilien import ZilienTSVReader, ZilienTMPReader +from .zilien import ZilienTSVReader, ZilienTMPReader, ZilienSpectrumReader from .ec_ms_pkl import EC_MS_CONVERTER READER_CLASSES = { @@ -32,5 +32,6 @@ "cinfdata": CinfdataTXTReader, "zilien": ZilienTSVReader, "zilien_tmp": ZilienTMPReader, + "zilien_spec": ZilienSpectrumReader, "EC_MS": EC_MS_CONVERTER, } diff --git a/src/ixdat/readers/reading_tools.py b/src/ixdat/readers/reading_tools.py index 002f914e..7c9f25df 100644 --- a/src/ixdat/readers/reading_tools.py +++ b/src/ixdat/readers/reading_tools.py @@ -10,6 +10,7 @@ STANDARD_TIMESTAMP_FORM = "%d/%m/%Y %H:%M:%S" # like '31/12/2020 23:59:59' USA_TIMESTAMP_FORM = "%m/%d/%Y %H:%M:%S" # like '12/31/2020 23:59:59' +FLOAT_MATCH = "[-]?\\d+[\\.]?\\d*(?:e[-]?\\d+)?" # matches floats like '5' or '-2.3e5' def timestamp_string_to_tstamp( diff --git a/src/ixdat/readers/zilien.py b/src/ixdat/readers/zilien.py index e428bc6c..035019fa 100644 --- a/src/ixdat/readers/zilien.py +++ b/src/ixdat/readers/zilien.py @@ -1,9 +1,11 @@ from pathlib import Path import re import pandas as pd -from ..data_series import TimeSeries, ValueSeries +import numpy as np +from ..data_series import DataSeries, TimeSeries, ValueSeries, Field from ..techniques.ec_ms import ECMSMeasurement -from .reading_tools import timestamp_string_to_tstamp +from ..techniques.ms import MSSpectrum +from .reading_tools import timestamp_string_to_tstamp, FLOAT_MATCH from .ec_ms_pkl import measurement_from_ec_ms_dataset ZILIEN_TIMESTAMP_FORM = "%Y-%m-%d %H_%M_%S" # like 2021-03-15 18_50_10 @@ -94,6 +96,52 @@ def series_list_from_tmp(path_to_file): return [tseries, vseries] +class ZilienSpectrumReader: + def __init__(self, path_to_spectrum=None): + self.path_to_spectrum = Path(path_to_spectrum) if path_to_spectrum else None + + def read(self, path_to_spectrum, cls=None, **kwargs): + """Make a measurement from all the single-value .tsv files in a Zilien tmp dir + + Args: + path_to_tmp_dir (Path or str): the path to the tmp dir + cls (Measurement class): Defaults to ECMSMeasurement + """ + if path_to_spectrum: + self.path_to_spectrum = Path(path_to_spectrum) + cls = cls or MSSpectrum + df = pd.read_csv( + path_to_spectrum, + header=9, + delimiter="\t", + ) + x_name = "Mass [AMU]" + y_name = "Current [A]" + x = df[x_name].to_numpy() + y = df[y_name].to_numpy() + with open(self.path_to_spectrum, "r") as f: + for i in range(10): + line = f.readline() + if "Mass scan started at [s]" in line: + tstamp_match = re.search(FLOAT_MATCH, line) + tstamp = float(tstamp_match.group()) + xseries = DataSeries(data=x, name=x_name, unit_name="m/z") + tseries = TimeSeries( + data=np.array([0]), name="spectrum time / [s]", unit_name="s", tstamp=tstamp + ) + field = Field( + data=y, name=y_name, unit_name="A", axes_series=[xseries, tseries] + ) + obj_as_dict = { + "name": path_to_spectrum.name, + "technique": "MS", + "field": field, + "reader": self, + } + obj_as_dict.update(kwargs) + return cls.from_dict(obj_as_dict) + + if __name__ == "__main__": """Module demo here. diff --git a/src/ixdat/spectra.py b/src/ixdat/spectra.py new file mode 100644 index 00000000..b6976616 --- /dev/null +++ b/src/ixdat/spectra.py @@ -0,0 +1,157 @@ +import numpy as np +from .db import Saveable, PlaceHolderObject +from .data_series import DataSeries, TimeSeries, Field + + +class Spectrum(Saveable): + """The Spectrum class""" + + table_name = "spectrum" + column_attrs = { + "name", + "technique", + "metadata", + "sample_name", + "tstamp", + "y_id", + } + + def __init__( + self, + name, + technique=None, + metadata=None, + sample_name=None, + reader=None, + field=None, + field_id=None, + ): + """ + Args: + name (str): The name of the spectrum + metadata (dict): Free-form spectrum metadata. Must be json-compatible. + technique (str): The spectrum technique + sample_name (str): The sample name + reader (Reader): The reader, if read from file + field (Field): The Field containing the data (x, y, and tstamp) + field_id (id): The id in the data_series table of the Field with the data, if + the field is not yet loaded from backend. + """ + super().__init__() + self.name = name + self.technique = technique + self.metadata = metadata + self.sample_name = sample_name + self.reader = reader + self._field = field or PlaceHolderObject(field_id, cls=Field) + + self._plotter = None + # defining this method here gets it the right docstrings :D + self.plot = self.plotter.plot + + @property + def plotter(self): + """The default plotter for Measurement is ValuePlotter.""" + if not self._plotter: + from .plotters.spectrum_plotter import SpectrumPlotter + + # FIXME: I had to import here to avoid running into circular import issues + + self._plotter = SpectrumPlotter(spectrum=self) + return self._plotter + + @classmethod + def read(cls, path_to_file, reader, **kwargs): + """Return a Measurement object from parsing a file with the specified reader + + Args: + path_to_file (Path or str): The path to the file to read + reader (str or Reader class): The (name of the) reader to read the file with. + kwargs: key-word arguments are passed on to the reader's read() method. + """ + if isinstance(reader, str): + # TODO: see if there isn't a way to put the import at the top of the module. + # see: https://github.com/ixdat/ixdat/pull/1#discussion_r546437471 + from .readers import READER_CLASSES + + reader = READER_CLASSES[reader]() + # print(f"{__name__}. cls={cls}") # debugging + return reader.read(path_to_file, cls=cls, **kwargs) + + @classmethod + def from_data( + cls, + x, + y, + tstamp=None, + x_name="x", + y_name="y", + x_unit_name=None, + y_unit_name=None, + **kwargs + ): + xseries = DataSeries(data=x, name=x_name, unit_name=x_unit_name) + yseries = DataSeries(data=y, name=y_name, unit_name=y_unit_name) + return cls.from_series(xseries, yseries, tstamp, **kwargs) + + @classmethod + def from_series(cls, xseries, yseries, tstamp, **kwargs): + tseries = TimeSeries( + data=np.array([0]), tstamp=tstamp, unit_name="s", name="spectrum time / [s]" + ) + field = Field( + data=yseries.data, + axes_series=[xseries, tseries], + name=yseries.name, + unit_name=yseries.unit_name, + ) + return cls.from_field(field, **kwargs) + + @classmethod + def from_field(cls, field, **kwargs): + spectrum_as_dict = kwargs + spectrum_as_dict["field"] = field + if "name" not in spectrum_as_dict: + spectrum_as_dict["name"] = field.name + return cls.from_dict(spectrum_as_dict) + + @property + def field(self): + if isinstance(self._field, PlaceHolderObject): + self._field = self._field.get_object() + return self._field + + @property + def xseries(self): + return self.field.axes_series[0] + + @property + def x(self): + return self.xseries.data + + @property + def x_name(self): + return self.xseries.name + + @property + def yseries(self): + return DataSeries( + name=self.field.name, data=self.field.data, unit_name=self.field.unit_name + ) + + @property + def y(self): + return self.field.data + + @property + def y_name(self): + return self.field.name + + @property + def tseries(self): + return self.field.axes_series[1] + + @property + def tstamp(self): + tseries = self.tseries + return tseries.data[0] + tseries.tstamp diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index bb86d740..ecf1e827 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -1,6 +1,7 @@ """Module for representation and analysis of MS measurements""" from ..measurements import Measurement +from ..spectra import Spectrum from ..plotters.ms_plotter import MSPlotter, STANDARD_COLORS from ..exceptions import SeriesNotFoundError, QuantificationError from ..constants import ( @@ -413,3 +414,11 @@ def gas_flux_calibration( cal_type="gas_flux_calibration", F=F, ) + + +class MSSpectrum(Spectrum): + """Nothing to add to normal spectrum yet. + TODO: Methods for co-plotting ref spectra from a database + """ + + pass From cc9e4da07ae05202b69fd74b22fd84c8a667730f Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Fri, 30 Apr 2021 14:45:04 +0100 Subject: [PATCH 057/118] fix spectrum for backend, write docstrings --- .../test_zilien_spectrum_reader.py | 7 +- src/ixdat/readers/zilien.py | 13 ++- src/ixdat/spectra.py | 91 +++++++++++++++++-- 3 files changed, 99 insertions(+), 12 deletions(-) diff --git a/development_scripts/reader_testers/test_zilien_spectrum_reader.py b/development_scripts/reader_testers/test_zilien_spectrum_reader.py index 1d4de58e..77f7dbd6 100644 --- a/development_scripts/reader_testers/test_zilien_spectrum_reader.py +++ b/development_scripts/reader_testers/test_zilien_spectrum_reader.py @@ -11,4 +11,9 @@ reader="zilien_spec", ) -spec.plot() +spec.plot(color="k") + +s_id = spec.save() + +loaded = Spectrum.get(s_id) +loaded.plot(color="g") diff --git a/src/ixdat/readers/zilien.py b/src/ixdat/readers/zilien.py index 035019fa..21d696dc 100644 --- a/src/ixdat/readers/zilien.py +++ b/src/ixdat/readers/zilien.py @@ -97,15 +97,21 @@ def series_list_from_tmp(path_to_file): class ZilienSpectrumReader: + """A reader for individual Zilien spectra + TODO: A Zilien reader which loads all spectra at once in a SpectrumSeries object + """ + def __init__(self, path_to_spectrum=None): self.path_to_spectrum = Path(path_to_spectrum) if path_to_spectrum else None def read(self, path_to_spectrum, cls=None, **kwargs): """Make a measurement from all the single-value .tsv files in a Zilien tmp dir + FIXME: This reader was written hastily and could be designed better. Args: path_to_tmp_dir (Path or str): the path to the tmp dir - cls (Measurement class): Defaults to ECMSMeasurement + cls (Spectrum class): Defaults to MSSpectrum + kwargs: Key-word arguments are passed on ultimately to cls.__init__ """ if path_to_spectrum: self.path_to_spectrum = Path(path_to_spectrum) @@ -130,7 +136,10 @@ def read(self, path_to_spectrum, cls=None, **kwargs): data=np.array([0]), name="spectrum time / [s]", unit_name="s", tstamp=tstamp ) field = Field( - data=y, name=y_name, unit_name="A", axes_series=[xseries, tseries] + data=np.array([y]), + name=y_name, + unit_name="A", + axes_series=[xseries, tseries], ) obj_as_dict = { "name": path_to_spectrum.name, diff --git a/src/ixdat/spectra.py b/src/ixdat/spectra.py index b6976616..897a13f7 100644 --- a/src/ixdat/spectra.py +++ b/src/ixdat/spectra.py @@ -4,7 +4,23 @@ class Spectrum(Saveable): - """The Spectrum class""" + """The Spectrum class. + + A spectrum is a data structure including one-dimensional arrays of x and y variables + of equal length. Typically, information about the state of a sample can be obtained + from a plot of y (e.g. adsorbance OR intensity OR counts) vs x (e.g energy OR + wavelength OR angle OR mass-to-charge ratio). Even though in reality it takes time + to require a spectrum, a spectrom is considered to represent one instance in time. + + In ixdat, the data of a spectrum is organized into a Field, where the y-data is + considered to span a space defined by the x-data and the timestamp. If the x-data + has shape (N, ), then the y-data has shape (N, 1) to span the x-axis and the + single-point t axis. + + The Spectrum class makes the data in this field intuitively available. If spec + is a spectrum, spec.x and spec.y give access to the x and y data, respectively, + while spec.xseries and spec.yseries give the corresponding DataSeries. + """ table_name = "spectrum" column_attrs = { @@ -12,8 +28,7 @@ class Spectrum(Saveable): "technique", "metadata", "sample_name", - "tstamp", - "y_id", + "field_id", } def __init__( @@ -26,7 +41,8 @@ def __init__( field=None, field_id=None, ): - """ + """Initiate a spectrum + Args: name (str): The name of the spectrum metadata (dict): Free-form spectrum metadata. Must be json-compatible. @@ -34,8 +50,8 @@ def __init__( sample_name (str): The sample name reader (Reader): The reader, if read from file field (Field): The Field containing the data (x, y, and tstamp) - field_id (id): The id in the data_series table of the Field with the data, if - the field is not yet loaded from backend. + field_id (id): The id in the data_series table of the Field with the data, + if the field is not yet loaded from backend. """ super().__init__() self.name = name @@ -78,6 +94,19 @@ def read(cls, path_to_file, reader, **kwargs): # print(f"{__name__}. cls={cls}") # debugging return reader.read(path_to_file, cls=cls, **kwargs) + @property + def data_objects(self): + """The data-containing objects that need to be saved when the spectrum is saved. + + For a field to be correctly saved and loaded, its axes_series must be saved + first. So there are three series in the data_objects to return + FIXME: with backend-specifying id's, field could check for itself whether + its axes_series are already in the database. + """ + series_list = self.field.axes_series + series_list.append(self.field) + return series_list + @classmethod def from_data( cls, @@ -90,17 +119,38 @@ def from_data( y_unit_name=None, **kwargs ): + """Initiate a spectrum from data. Does so via cls.from_series + + Args: + x (np array): x data + y (np array): y data + tstamp (timestamp): the timestamp of the spectrum. Defaults to None. + x_name (str): Name of the x variable. Defaults to 'x' + y_name (str): Name of the y variable. Defaults to 'y' + x_unit_name (str): Name of the x unit. Defaults to None + y_unit_name (str): Name of the y unit. Defaults to None + kwargs: key-word arguments are passed on ultimately to cls.__init__ + """ xseries = DataSeries(data=x, name=x_name, unit_name=x_unit_name) yseries = DataSeries(data=y, name=y_name, unit_name=y_unit_name) return cls.from_series(xseries, yseries, tstamp, **kwargs) @classmethod def from_series(cls, xseries, yseries, tstamp, **kwargs): + """Initiate a spectrum from data. Does so via cls.from_field + + Args: + xseries (DataSeries): a series with the x data + yseries (DataSeries): a series with the y data. The y data should be a + vector of the same length as the x data. + tstamp (timestamp): the timestamp of the spectrum. Defaults to None. + kwargs: key-word arguments are passed on ultimately to cls.__init__ + """ tseries = TimeSeries( data=np.array([0]), tstamp=tstamp, unit_name="s", name="spectrum time / [s]" ) field = Field( - data=yseries.data, + data=np.array([yseries.data]), axes_series=[xseries, tseries], name=yseries.name, unit_name=yseries.unit_name, @@ -109,6 +159,15 @@ def from_series(cls, xseries, yseries, tstamp, **kwargs): @classmethod def from_field(cls, field, **kwargs): + """Initiate a spectrum from data. Does so via cls.from_field + + Args: + field (Field): The field containing all the data of the spectrum. + field.data is the y-data, which is considered to span x and t. + field.axes_series[0] is a DataSeries with the x data. + field.axes_series[1] is a TimeSeries with one time point. + kwargs: key-word arguments are passed on ultimately to cls.__init__ + """ spectrum_as_dict = kwargs spectrum_as_dict["field"] = field if "name" not in spectrum_as_dict: @@ -117,41 +176,55 @@ def from_field(cls, field, **kwargs): @property def field(self): + """Since a spectrum can be loaded lazily, we make sure the field is loaded""" if isinstance(self._field, PlaceHolderObject): self._field = self._field.get_object() return self._field + @property + def field_id(self): + """The id of the field""" + return self.field.id + @property def xseries(self): + """The x DataSeries is the first axis of the field""" return self.field.axes_series[0] @property def x(self): + """The x data is the data attribute of the xseries""" return self.xseries.data @property def x_name(self): + """The name of the x variable is the name attribute of the xseries""" return self.xseries.name @property def yseries(self): + """The yseries is a DataSeries reduction of the field""" return DataSeries( - name=self.field.name, data=self.field.data, unit_name=self.field.unit_name + name=self.field.name, data=self.y, unit_name=self.field.unit_name ) @property def y(self): - return self.field.data + """The y data is the data attribute of the field""" + return self.field.data[0] @property def y_name(self): + """The name of the y variable is the name attribute of the field""" return self.field.name @property def tseries(self): + """The TimeSeries is the second of the axes_series of the field""" return self.field.axes_series[1] @property def tstamp(self): + """The value with respect to epoch of the field's single time point""" tseries = self.tseries return tseries.data[0] + tseries.tstamp From 27ca576754b6d061c87363b2aec6feee19f4d22b Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Thu, 1 Jul 2021 13:24:21 +0100 Subject: [PATCH 058/118] msrh spectroelectrochemistry reader --- .../reader_testers/test_msrh_sec_reader.py | 16 ++++ src/ixdat/data_series.py | 6 ++ src/ixdat/readers/__init__.py | 4 + src/ixdat/readers/msrh_sec.py | 83 +++++++++++++++++++ src/ixdat/techniques/analysis_tools.py | 29 +++++++ .../techniques/spectroelectrochemistry.py | 8 ++ 6 files changed, 146 insertions(+) create mode 100644 development_scripts/reader_testers/test_msrh_sec_reader.py create mode 100644 src/ixdat/readers/msrh_sec.py create mode 100644 src/ixdat/techniques/spectroelectrochemistry.py diff --git a/development_scripts/reader_testers/test_msrh_sec_reader.py b/development_scripts/reader_testers/test_msrh_sec_reader.py new file mode 100644 index 00000000..3c9ad941 --- /dev/null +++ b/development_scripts/reader_testers/test_msrh_sec_reader.py @@ -0,0 +1,16 @@ +from pathlib import Path +from ixdat.readers.msrh_sec import MsrhSECReader + +data_dir = Path(r"C:\Users\scott\Dropbox\ixdat_resources\test_data\sec") + +path_to_sec = data_dir / "test-7SEC.csv" +path_to_wl = data_dir / "WL.csv" +path_to_jv = data_dir / "test-7_JV.csv" + +sec_meas = MsrhSECReader().read( + path_to_sec, path_to_wl, path_to_jv, scan_rate=1, tstamp=1 +) + +sec_meas.plot() +sec_meas.whitelight_spectrum.plot() +print(sec_meas["spectra"].data.shape) diff --git a/src/ixdat/data_series.py b/src/ixdat/data_series.py index 8ff708a7..3feed769 100644 --- a/src/ixdat/data_series.py +++ b/src/ixdat/data_series.py @@ -251,6 +251,12 @@ def data(self): ) return self._data + @property + def tstamp(self): + for s in self.axes_series: + if isinstance(s, (ValueSeries, TimeSeries)): + return s.tstamp + class ConstantValue(DataSeries): """This is a stand-in for a VSeries for when we know the value is constant""" diff --git a/src/ixdat/readers/__init__.py b/src/ixdat/readers/__init__.py index b7d4a26e..f6b0b2ca 100644 --- a/src/ixdat/readers/__init__.py +++ b/src/ixdat/readers/__init__.py @@ -23,6 +23,9 @@ from .zilien import ZilienTSVReader, ZilienTMPReader, ZilienSpectrumReader from .ec_ms_pkl import EC_MS_CONVERTER +# spectroelectrochemistry +from .msrh_sec import MsrhSECReader + READER_CLASSES = { "ixdat": IxdatCSVReader, "biologic": BiologicMPTReader, @@ -34,4 +37,5 @@ "zilien_tmp": ZilienTMPReader, "zilien_spec": ZilienSpectrumReader, "EC_MS": EC_MS_CONVERTER, + "msrh_sec": MsrhSECReader, } diff --git a/src/ixdat/readers/msrh_sec.py b/src/ixdat/readers/msrh_sec.py new file mode 100644 index 00000000..d830f81e --- /dev/null +++ b/src/ixdat/readers/msrh_sec.py @@ -0,0 +1,83 @@ +from pathlib import Path +import numpy as np +import pandas as pd +from .reading_tools import prompt_for_tstamp +from ..data_series import DataSeries, TimeSeries, ValueSeries, Field +from ..techniques.analysis_tools import calc_t_using_scan_rate + + +class MsrhSECReader: + def read( + self, + path_to_file, + path_to_wl_file, + path_to_jv_file, + scan_rate, + tstamp=None, + cls=None, + ): + if not cls: + from ..techniques.spectroelectrochemistry import SpectroECMeasurement + + cls = SpectroECMeasurement + + path_to_file = Path(path_to_file) + path_to_wl_file = Path(path_to_wl_file) + path_to_jv_file = Path(path_to_jv_file) + + sec_df = pd.read_csv(path_to_file) + whitelight_df = pd.read_csv(path_to_wl_file, names=["wavelength", "intensity"]) + jv_df = pd.read_csv(path_to_jv_file, names=["v", "j"]) + + spectra = sec_df.to_numpy()[:, 1:].swapaxes(0, 1) + + wl = whitelight_df["wavelength"].to_numpy() + excess_wl_points = len(wl) - spectra.shape[1] + wl = wl[excess_wl_points:] + whitelight = whitelight_df["intensity"].to_numpy()[excess_wl_points:] + + wl_series = DataSeries("wavelength", "nm", wl) + white_series = Field( + "white_light", + "counts", + axes_series=[wl_series], + data=np.array([whitelight]), + ) + + v = jv_df["v"].to_numpy() + j = jv_df["j"].to_numpy() + excess_jv_points = len(v) - spectra.shape[0] + v = v[:-excess_jv_points] + j = j[:-excess_jv_points] + t = calc_t_using_scan_rate(v, dvdt=scan_rate * 1e-3) + + tstamp = tstamp or prompt_for_tstamp(path_to_file) + tseries = TimeSeries( + "time from scan rate", unit_name="s", data=t, tstamp=tstamp + ) + v_series = ValueSeries("raw_potential", "V", v, tseries=tseries) + j_series = ValueSeries("raw_current", "mA", j, tseries=tseries) + spectra = Field( + name="spectra", + unit_name="counts", + axes_series=[tseries, wl_series], + data=spectra, + ) + series_list = [ + tseries, + v_series, + j_series, + wl_series, + white_series, + spectra, + ] + + measurement = cls( + name=str(path_to_file), + tstamp=tstamp, + series_list=series_list, + raw_potential_names=(v_series.name,), + raw_current_names=(j_series.name,), + ) + + return measurement diff --git a/src/ixdat/techniques/analysis_tools.py b/src/ixdat/techniques/analysis_tools.py index 8ea94d09..3077b6e5 100644 --- a/src/ixdat/techniques/analysis_tools.py +++ b/src/ixdat/techniques/analysis_tools.py @@ -1,4 +1,5 @@ import numpy as np +from scipy.optimize import minimize def tspan_passing_through(t, v, vspan, direction=None, t_i=None, v_res=None): @@ -165,3 +166,31 @@ def find_signed_sections(x, x_res=0.001, res_points=10): i_start += res_points return sections + + +def calc_t_using_scan_rate(v, dvdt): + """Return a numpy array describing the time corresponding to v given scan rate dvdt + + This is useful for data sets where time is missing. It depends on another value + having a constant absolute rate of change (such as electrode potential in cyclic + voltammatry). + It uses the `calc_sharp_v_scan` algorithm to match the scan rate implied by the + timevector returned with the given scan rate. + Args: + v (np array): The value + dvdt (float): The scan rate in units of v's unit per second + Returns: + np array: t, the time vector corresponding to v + """ + + def error(t_tot): + t = np.linspace(0, t_tot[0], v.size) + dvdt_calc = np.abs(calc_sharp_v_scan(t, v)) + error = np.sum(dvdt_calc ** 2 - dvdt ** 2) + return error + + t_total_guess = (max(v) - min(v)) / dvdt + result = minimize(error, np.array(t_total_guess)) + + t_total = result.x + return np.linspace(0, t_total, v.size) diff --git a/src/ixdat/techniques/spectroelectrochemistry.py b/src/ixdat/techniques/spectroelectrochemistry.py new file mode 100644 index 00000000..2e5c38e3 --- /dev/null +++ b/src/ixdat/techniques/spectroelectrochemistry.py @@ -0,0 +1,8 @@ +from .ec import ECMeasurement +from ..spectra import Spectrum + + +class SpectroECMeasurement(ECMeasurement): + @property + def whitelight_spectrum(self): + return Spectrum.from_field(self["white_light"]) From aa947aaa5a75b48db6d6b42d26823d94a6658d87 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Thu, 1 Jul 2021 18:26:02 +0100 Subject: [PATCH 059/118] spectroelectrochemistry plotting --- .../reader_testers/sec_example.png | Bin 0 -> 73749 bytes .../reader_testers/sec_waterfall_example.png | Bin 0 -> 99254 bytes .../reader_testers/test_msrh_sec_reader.py | 18 ++- src/ixdat/measurements.py | 4 +- src/ixdat/plotters/sec_plotter.py | 111 ++++++++++++++++++ src/ixdat/readers/msrh_sec.py | 29 ++--- src/ixdat/techniques/__init__.py | 2 + .../techniques/spectroelectrochemistry.py | 47 +++++++- 8 files changed, 188 insertions(+), 23 deletions(-) create mode 100644 development_scripts/reader_testers/sec_example.png create mode 100644 development_scripts/reader_testers/sec_waterfall_example.png create mode 100644 src/ixdat/plotters/sec_plotter.py diff --git a/development_scripts/reader_testers/sec_example.png b/development_scripts/reader_testers/sec_example.png new file mode 100644 index 0000000000000000000000000000000000000000..e1d208d9226150d4a6df6bcb72196a19995ca4fd GIT binary patch literal 73749 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 RYGpA1fv2mV%Q~loCIIC(rpf>S literal 0 HcmV?d00001 diff --git a/development_scripts/reader_testers/sec_waterfall_example.png b/development_scripts/reader_testers/sec_waterfall_example.png new file mode 100644 index 0000000000000000000000000000000000000000..836f5e398c10600f2f104af2ab203274cf1c59d5 GIT binary patch literal 99254 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ℜ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

AqZ74P1 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 Date: Thu, 1 Jul 2021 20:12:20 +0100 Subject: [PATCH 060/118] v0.1.4sec --- .gitignore | 6 +++++- .../reader_testers/sec_example.png | Bin 73749 -> 0 bytes .../reader_testers/sec_waterfall_example.png | Bin 99254 -> 0 bytes .../reader_testers/test_msrh_sec_reader.py | 8 ++++---- src/ixdat/__init__.py | 2 +- 5 files changed, 10 insertions(+), 6 deletions(-) delete mode 100644 development_scripts/reader_testers/sec_example.png delete mode 100644 development_scripts/reader_testers/sec_waterfall_example.png diff --git a/.gitignore b/.gitignore index c80bdc64..40ad90ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# Examples +*.png +*.csv + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -133,4 +137,4 @@ dmypy.json # More temporary files \#*\# -*~ \ No newline at end of file +*~ diff --git a/development_scripts/reader_testers/sec_example.png b/development_scripts/reader_testers/sec_example.png deleted file mode 100644 index e1d208d9226150d4a6df6bcb72196a19995ca4fd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 73749 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 RYGpA1fv2mV%Q~loCIIC(rpf>S diff --git a/development_scripts/reader_testers/sec_waterfall_example.png b/development_scripts/reader_testers/sec_waterfall_example.png deleted file mode 100644 index 836f5e398c10600f2f104af2ab203274cf1c59d5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 99254 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ℜ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

AqZ74P1 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 Date: Fri, 2 Jul 2021 02:11:38 +0100 Subject: [PATCH 061/118] major update to documentation --- .gitignore | 7 +- docs/source/conf.py | 1 + docs/source/developing.rst | 93 ++ docs/source/extended-concept.rst | 10 +- .../21F03_DWS4_frontend_and_planning.png | Bin 0 -> 84236 bytes docs/source/figures/ixdat_example_figures.png | Bin 0 -> 198930 bytes docs/source/figures/ixdat_flow.png | Bin 0 -> 101429 bytes docs/source/figures/ixdat_flow.svg | 1137 +++++++++++++++++ docs/source/figures/logo.svg | 172 +++ docs/source/index.rst | 42 +- docs/source/introduction.rst | 74 +- docs/source/tutorials.rst | 6 + logo.svg | 172 +++ 13 files changed, 1686 insertions(+), 28 deletions(-) create mode 100644 docs/source/developing.rst create mode 100644 docs/source/figures/21F03_DWS4_frontend_and_planning.png create mode 100644 docs/source/figures/ixdat_example_figures.png create mode 100644 docs/source/figures/ixdat_flow.png create mode 100644 docs/source/figures/ixdat_flow.svg create mode 100644 docs/source/figures/logo.svg create mode 100644 docs/source/tutorials.rst create mode 100644 logo.svg diff --git a/.gitignore b/.gitignore index c80bdc64..c501bbcc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ +# Big files from tests +*development_scripts/*.png +*development_scripts/*.svg +*docs/source/figures/big/* + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -133,4 +138,4 @@ dmypy.json # More temporary files \#*\# -*~ \ No newline at end of file +*~ diff --git a/docs/source/conf.py b/docs/source/conf.py index 2e1bd2b2..6d827791 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -45,6 +45,7 @@ "sphinx.ext.coverage", "sphinx.ext.napoleon", "sphinx_rtd_theme", + "sphinx.ext.viewcode", ] source_suffix = {".rst": "restructuredtext", ".txt": "restructuredtext"} diff --git a/docs/source/developing.rst b/docs/source/developing.rst new file mode 100644 index 00000000..873d95a4 --- /dev/null +++ b/docs/source/developing.rst @@ -0,0 +1,93 @@ +================ +Developing ixdat +================ + +If there's an experimental technique or analysis procedure or database or that ixdat +should support and doesn't, it might be because **you** haven't coded it yet. + +Here are a few resources to help you get started developing ixdat. + +Github +****** + +The source code for ixdat (and this documentation) lives at: +https://github.com/ixdat/ixdat + +Note, we are currently compiling from the +`[user_ready] `_ +branch, not the master branch. + +To develop ixdat, you will need to use git and github. This means + +- `install git `_. Git bash is strongly recommended for Windows +- Create an account at https://github.com +- Clone the repository. Navigate in the terminal which you will use for git (e.g. git bash) to + the location that you want the repository (e.g. ``/c/Users/your_user_name/git/``), and type:: + + git clone https://github.com/ixdat/ixdat + +- Install ixdat from the repository to use the ixdat code you're working on. You can do this in a virtual environment, + but it is simpler to just install it dynamically. In your terminal or Anaconda Prompt, navigate + to the folder which contains the ixdat project folder (e.g. ``/c/Users/your_user_name/git/``) + and type:: + + pip uninstall ixdat + pip install -e ixdat + + If you want to go back to using the released version later, just re-install it from PyPi:: + + pip install --upgrade ixdat + +- Make a branch using:: + + git branch my_branch_name + git checkout my_branch_name + + +- Develop your feature, commiting regularly and pushing regularly to your github account. + +- When it's ready (i.e., works like you want, and passes linting and testing), make a pull request! + A pull request (PR) is an awesome open review process that gives others a chance to comment and suggest + improvements in your code before it's merged into the main ixdat package. You can see + existing pull requests at https://github.com/ixdat/ixdat/pulls + +style +***** + +We do our best to follow the conventions at + +- code style guide: https://www.python.org/dev/peps/pep-0008/ +- docstring style guide: https://www.python.org/dev/peps/pep-0257/ + +Exceptions include + +- It's fine to capitalize names for a quantity that is conventionally capitalized in equations (`T` for temperature, for example). + +The tools **black** and **flake8** help us keep the style up to standards. + +tools +***** + +We use tools to make sure that our code is both functional and pretty. This makes it +easier to work together. See instructions for the tools in `tools.rst `_ + +Note on ongoing developments +**************************** + +If you develop now, pretend that **[user_ready]** is the master branch. We are working +though some issues in the guts of ixdat to make sure it'll be able to scale to large +projects with SQL backends. Hopefully this won't change the API much, so that the updated +guts won't require changes in your code. Here is what we're going through: + +.. figure:: figures/21F03_DWS4_frontend_and_planning.png + :width: 500 + + Git plan as of 21F03 (June 3, 2021) + + +Write to us +*********** +We'd love to know what you're working on and help with any issues developing, even +before you make a PR. + +For now, best to reach Soren at sbscott@ic.ac.uk \ No newline at end of file diff --git a/docs/source/extended-concept.rst b/docs/source/extended-concept.rst index 341c912f..7cfd1aa8 100644 --- a/docs/source/extended-concept.rst +++ b/docs/source/extended-concept.rst @@ -1,9 +1,15 @@ .. _concept: +================ Extended concept ----------------- -*By Soren B. Scott, 20H03 (August 3, 2020)* +================ + +.. figure:: figures/ixdat_profile_pic.svg + :width: 200 + The power of combining techniques (fig made with ``EC_Xray``, an ``ixdat`` precursor) + +*By Soren B. Scott, 20H03 (August 3, 2020)* My idea is that ``ixdat`` will have two "faces": diff --git a/docs/source/figures/21F03_DWS4_frontend_and_planning.png b/docs/source/figures/21F03_DWS4_frontend_and_planning.png new file mode 100644 index 0000000000000000000000000000000000000000..f511ae1dab7ec7846ed5ea3529f0a02e4fae6533 GIT binary patch literal 84236 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 literal 0 HcmV?d00001 diff --git a/docs/source/figures/ixdat_example_figures.png b/docs/source/figures/ixdat_example_figures.png new file mode 100644 index 0000000000000000000000000000000000000000..eab8cf9b81e76355e7360d0175e2944ac7b9afdc GIT binary patch literal 198930 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ʫv-&pKq!#&!S zQd5A?tfm1JNhu*p>WwGIShvtom=du9=f8S;b0<*mr*~WMY~*~h^r=jasT1WgBikK4 zT|7|sU`pZ(Uf76mxayPb7NtU|U%JP7jsgQl-?b&L;R20$UytlmaqT$Yka)-{AgoQV?(7d5n<(`p>fv9>DvmHYu!R^ zP3?dpe#An&082-d!y9qhjGk3*yA~2cEa>m!ci1;fS65f(S_MW11+)U1IWsd;;*~fq zv79qW63xc%kM2JYy9z1B_Y?2$jjLy8KNwk$_mCYW8TkB>XqM@2g7mw6i{!Nr62@Mg zqoj#$yqG!{L7-cGkk>Jb*iFinZy5ACyZ&-bs@nqaxkF=68;{&;NT3fo@Nu>Rt>cL$BJ*4&q%&AzW5@s)mAqU`bPuHt1X`@W5nXDgZd~zI-nxP zL_?_h_{ptxbcVnnmxS9p=$z}bN`-Y*g4yiGIj@*6HaDp>*HQ909-GfFA3X74^AAB9 zw=f&pGsK8cmiE}=1qAbrR&D6s7L}GR=YY42b|_4j5ceeQnT7^HylM~7PD~Luih*9V z;v)v)?oh+sA-R207XDJkHp>w2s6Rf))wq-I<;XjR{+c}s;l*4 zVY_c^%N6OP<_4L30;SCkIXn9XT01lG&4H<@$6$Mm8@l+q^pu))-af6Yj0185&;NeWuTs8(|_e@`39Uo)OXH8@SFbe@u}N1oRr_p~tFMOyE) z3~sFWNQx!RiIr<@PsZ4TY#_=4Ws!-`pLqv zDs!bv^mlLH`m0Ku1;O5O2yFZR+vG0O?|mm5*(LN%{O90%rEzd~hh@#&>S||Vxuo-} z9J4hnjeN+;ZY8EQ*}{U@K4#)FuGwQIA`qL`PuqG+%px-*<_$Y-pSv6)0;?MODpaZx zP68XLvY+1DOnnGpDXPJga@@iK?=zLUEd$n9iJ$$AKUZ-+XEG>#KeNXb zpFNC&mns1^d7c)$kK=jOJ<7J;ZDvfhWcOh$gD#>hT>ocGgAZjfwtBA!KlXSP^Yyh! zlSYT#YbmY?1clj@D&W7w20)C6Y�tK>^%!=|N-vroWOv#hEXyzLRp)TfGzB!FDXK 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

>JSv6ra#dC>bXsHIKew>w>dT7 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 literal 0 HcmV?d00001 diff --git a/docs/source/figures/ixdat_flow.png b/docs/source/figures/ixdat_flow.png new file mode 100644 index 0000000000000000000000000000000000000000..5c3f2d69f0f8857b9d242e27cec99163e765aaa9 GIT binary patch literal 101429 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%0DHV2!8vWJ%hI#v}*i72TN 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 Ubx^nZuV)J7X)jJ0?f20O&^yr%bKSX* z@1>5kj7{QvTg~D(gg|9+te2?;>X+*>R zAfr7z$d6wgsJB}~eSi8cn@^(1Uf0dMuy7fGuvySKecYOSukncUC&4%PZ7uot8A)Wuh+uKQlEDiurpJyk!#hAy0jlKn(rhKd{-zsWh}0{JDaMZ0HX$!i8$$1U(Sv znrIy>71^-&mjKxi^LH$h-W`$@IftY3nP7<@e_0;8&u5oG6tyaafcfiTgldLCc0&nt zVigtQpJX^q?DT4t*N|w6Ujk*Al{}60BX6Jarc&L$7%hwq5?iiq8Miz)P)1m-IfI7< zRIrl?fY^{J-7@dtCoyTGuu8MweFR0x%fyO36mH4gz;Fy5!C*2JDhtoA>niAd&Fz4U z0G-P;J-9cXUj9MamD*Kj1VR^D7aGD&O(cn?fr^j!N}tcvmL>0LQ88`ba5r}~tZ3J^ zcEqYweIAG@-_wUP*Cs1XR;fszA|nj?NErG*;Vb;89EbrHrQbNJboSK+cP&!q)9rP< z>6NpFG)w&b>9{NtjzpXHBuR3drPNPv2I@h)=8|S{{-~T7;}xXZcNgoHZD$wpq2~W_ z0Z6s6>aKrNAERn&O2ks<6?!8*Jye)P%#hyvpMv}qpmUgOD|ld8wACLidnOi%p9Xnw z8Bi=h6)P6NwAh=lTS;;Zy3H>(c#>eeh7 zFG*S5PpQY~s+>c=i{#zvSp+8S9FKHEcNjO-RoJJ%d|;oE>p%tQ`mM$;j{%A63xuM@TMOS2k1WfOwFy)L3sWKrR0TaEQuH zA29=`6Ht~aX|N4k9%MNG)q$4akkGAG##JB7LsES>F6BNJsN_97R-lZAcYY~%ZsD&nDUypBd{}InE)#!o^RQI z{k`sR8?d2l<}58MSDti*917Fh)k85XbrJ|1cn8sN6>1aDN4Q3h^#yFgfya~R~ z)L!e;5B&WG@b;(^Yz27vcLH@WJN+*}pIfvhP;HPHtZLMohg@Ex-#_=cllV~JL&3d3 z++0|RZc-|UX(HPw0H%xjo@NR;n!DX9GU1vB70dQ_Vs<73B~!)DvMqq4Au2p=llWPS zl^IWqokJ>&548$aLP>I$86KclWc#ttWc{+AH+gBzx@B;6#$WMgBMK#sr0ECA^hB^I z8LKp~HXy=~){@AIl%T=kM@!KVC^4?YHr;LLbUL1v$shqBNZAx5nSpfd8xt+MIjxV9 zQRq88c#>hWBzr36aB}QV^yi@r$RR`6kvmJzuEm?1*dNc~uJHEvl3`NV5Xv`})1X%f zM>sXN0O>a>#49)s^u@;71B=M!91o>~l^b@Lr=M0_%B*3f0RD;I&0mSo`Hg2R0|#`p zJxRY4{i#o?gs*eg6;Ua-7 z(pjpar39@~N3GCyh`HXi7~&-)W2cy*;Nm5AA<@RN%Jg*-+@aOnTp>APcZajOcB9`_ zMx5(!6hPEsbLBCmNxeUHJ)AStSh|k*q6AbYwGwtTy2A=wFj%2m3>Ww^sPVI*bp|lW!>pZd|-q2@3A89Wfv+#c)cWxn*89TZL9 zpR=B_v1o=CLcMyD?`_DG=28R+1>Zl`2raQNXc*;%>7amcz|JDu*8&J{i$k&a25RZ3 zzWg>@ZR6>(m2+rC7z7XEJmH4jQA*w1R6+XjoiQf8s(L<-V_B!z)yaF}N@|AmtW4Nh zZ?O&oda!ZzU6S&26&V9@_Jd&u)Dre^(ha-pD%Th|-cc1vrtHRhQ!sFL=q5JL(77V# zQw#l2W?Y>xgPP)ey2{MoNq5nOe$@!A41?rDm@2mo!<=_VPWHd@>Mt=lEL~}ZHSFpv z%*J4)lbgGHqZG6wA=T>XaM*0sOg@@g0xl;To;BrbGIBmxV;ZYH=>3UfOraN>tq{wz3 z8*1*@dR#dP>taV!AJ^ZHNQDSMZw%8y_B4PsI^LBh#}ZD=KzD=JJP$U0JHW3jWvt+7 znw$bw7{-K#K(>7+bZ(tlNI1-LT4eO&B%QwhzA-#2oBnK;UIgB#R0~w$@E%yio>MpF z;+S8T7cG6|!Bi`aK4C#ybg2+CT0^{Nq^R!b%F&@h=iWfROo-p(@&0XiTnabheUt}~ z*%-9r)BjApRv}47XWehfJiqwbkOCHV=HN#{rL{s)*{W&FWo6{V)8 zEPs(A+y?wL1~IHVBDj$6iN^|mI2|jJB$dl{^TL;Pmdx9}*x)~=Q$K$k2iZ9r572#_ z;q0Y%x7@n4#(kqvy_B$Fp`6(g`hjeGJ1je_>ue}G)7Q+sp>9TFj> zdZO2qJ_W~Gks(e$`~<1m7Sz0mJMYQV@V6mi4T(wLhtz%G*YW%5mo}fwJV*bqEY(o( zbXVco1f-oxl~z+E7p0Efi~dUBmg>Fg^SjNvY}mu9rzk4AzHA8M8`;|VndIjmc7PRZ zI$~pYWLaF4fA`&RIOV#@x~Sa5B&(@;%;v!Am-v4!4G9m6BUy5woU+^bkxu z_sT7pY!@%!$Y1X##ZDD8JCsL084p==L<+iyBa>36;D>vA_yx^{*^auI#s~vLsnNP| zwpJyb6L1erXPFH#vmJr^`@S9pe7?_0dcM#5j>lm{WfzP;$7ZUUq6D7XPd;3KO)~z$ zp3lITdSbtF*pbXCTPHQ}_lr}cZFUB1VuCLFN6{th)TqkK(D%pd^MOZ@2Ga+Rv*r}v zxj@rDuik-=(7-b4Ng6>x$e?KH7>^G0aX-QfBlwJ^N!+?-B3NOA(rg~wZP6|JJM;DU=7PFDGpsh zZ&%^%VFx(fSi|DJ#bFxtbD=E>5+}Etg7~}h$a#oAP4hRG62~@mTk;%nm@g!5-f^G4 zYbSSA?`_#I3}?r2A(B3DtG^$=k#nUVwn8}j_Mi#Y0p%XelRQShx9eFOVl9wC9gG(r z^xnLq`_USJI3%aMr;SpSIgpL+6xP*j8hqW~>=S9)5}BmeC{iwBU28&iuINW|O{%YZ z>)B@z61j&@iqMOJQr4}nuGnIb+lo}}T1Ur#8jNyP47Vnj+Oq`phhQIg7x z#vjSZM{uFJA1ab|YSf8paJHX}bmDeA=8IYpsYmecJ}UwK1@l@=b_#paCOemeEK2*kA(5~~>yY&D<&;&XgqIKnHo@sMDS$OzigKD?u#@&E0 zr8PM{ot1!_W*$Wq_IHFOQUz;UZXk2Q+^4cHC7_B1Qd>h`R@{juo(As}iHw~=Tul-Q z{d>&%3Aa_CQ{C&)KbqoIN&N*$^tK}by~hk=o*$(!uJzQk$j*rzhmDUeQ!PxB^dc$L z@owqfkG1uinH)Gz&>tWztfGI#GD+(+Id*$KCF;j!WnykK|5AZN-Q<$ewfEp~5cl_$ zHRtKGl7X0KP!eHcF23{VW`BwtzB?*tl^s=G(J=GR3H6JILBx{*ppM zK&3ym_^rUjnaA2V>+3fIM?8rX6&|TS@3=rQYgiAzn})h>#hcy)>bBQynpz}uHna?- zCvb5U@WHyTR+s?I=v+aOW9c06sI&e))1kk8v~wWO*s~w(-NFvOZ2s&5je`=T5t9SqG2guD|NeUZwJ{fRo=?@;;Z` zL(ZZ#eG?pns^hW&LguG$mE;c=cEv%o2<|#Mfnw-Xv+_~O+}I>P>$Yq&weBPTb{!qO zHQ`w=jp=j1j8!;asBt^~4gKw%NC%<#wC4Lw$+qsB-2n-7)z4fMRl4 zH%0v8ByS`|1K%KTSc3t{{c38tD8&CcpVboR?8x-mQ*{hR6d>EoAGXuV8D+p`3K%)SEs^0`K^$NXYp88cqzw%oMPhaDM8i9VDoQ< zXakwohV<9%(=G-nQHgZ2AL3G&)e0EkWVPyQ4{uWWP^z?p!)AM7UpfOPbZ)+68n@D3 zqdTsZFY#9xvglqOFh+bt>{6w$(8t$S{0M4fDjYWl*YQ|lp!isROw?vbPNWRsn|e{M z2!{z52&Pqmg;-@NkS$KflD;B%U5QPJm{fbS38o!vQ=t~_!W zM>Y{MEA}g2_vJRCh9_^uuA2;cCR^Oj1uM+Ua_-1tCkQc^_P&1un@b9;Q)q}CMe)HH zdfD5|n{^?VhxwgV#82vw{q3;tSN=^R zIMI;D;;<^=otWz^tL)d~*G>!VDx0l-LxDqh9rP2bVa&sVF0iQOvmHmhBF4k>?N^4= zCChb8*~sR$M=WDT&;!_RWpW}KFK*(8umo}6m0wW#+Iv6m7nf9z&uTT51yCVmti9x& z1|%Udt+;dA_vf(v9-0>?N{d*eb1yrXizdrCH=C)FKjN^#p>A|KwqD=9=n|KFC}vvM zM=^)$qeoFCka1*qYCwtKX|1Yo6578AVj5Df#u8$;cKXF><@em`?p*KZLR%vHXa-dZ z|IL`rsgNk!<`wFdgwsXRY1@{EGKbxj+kt!9(59eFsq3~J$u-#7W7|KcPn_Y_s%E0k z;lwGvP7}u@?v5?0hE340bZRu#MQDk}#5sT_$}5Mrul?9-O98uOkrrFBZjV!zU`9iT)>wgxkytn7VeQ9#Q zY?Wu>Ifo>q4nI4gJ(K4aa1TnC^%9UW>M*{saC&v{A5+7r-ECIn0gZr}TE_%(2+cUZy&B5xE4&1Oi1Tft|zyHxT&C5mR1;2DDS_&_XI9tB}ghQwwd& zgn^BTkDU`RO9P9jw0VRJHz?X{JR_IDK;Tvxngmlz3N#P|wGfXSt5ATuH&-20RQvL5E6cAXu<_c##bGoDdN3N@nH7`jaSM32p!qp|luv`g zbN@kQ4zk;RVg~$&s1v3&HG_o!VoHe~k>!EYG37(Z3f}w7wb|WXf25PYaX+A8|9Q=j@)e z`H3%9rk`LOu01r7l5?yuUi}_XhQ+UG>*o$Q-!HeRPenVAcGq~XtEfu%Xnp*8%!y5M zqHga{n4zRXT%U{BDC)(YP&HINi&7fB`H+Y97C;>;Y~o}-BN#lXW!R6hh#!3Pj)P~k zm(dGk|Cq*bK{{v}9bH{G%$sftE6yE~DETUaKr?jtM7mkmhM56q6DtHAs77ilL{x9d zZKQD}fBI@}&sqkcmYpUJZ5vC9e{J6Ae+7R&gv&6TLDf=D)vU7MQY26e=?_+1w@u@5 ztvY_Pga35@{Pv2C-7_to|_XLTjA>U~Zh*J|h@z4g#Pdv&kmbi45}^yo+6k-Xti z#XOhT$7+{eVp{X6GjXj6OD{Q2dopgfgW0QHcno=>gb$Wz&xI^Dk!qv^kgWUE^_Y~_(LW^J2-Vt~kd@YljHnCK%jtJJ=Q-?(86Bl{j z2y-kuJ5~B|ddD=afa<;-XL`~?DBniU$DAn6rC=z1T0`#@D4u<@#D8(onm`^uGdEHy z^?SFd3=uA!;B(9WVTf6;s8rNjHW;N@oas>9@}>9sJqdh}cxM?%QX!UzaArMvNFdw0 zL!Y{s{V|nT7b`}-#76wSX~~5(3vn&w8i2mLkkFU+(RRu$ZQlj@06VFgK=Es;&g4(h5 z_&F|?#%1vA%Z=W&eE%w;V>6BuV!1Gf+B|@rUO%v3bf5jh7~H%>Z=&@TS$_>Apg+3t z25vs%lCbugyA*(>m+RWjy9p%mxof8UMyCjt_QjBHJNA<9P5Jt;>&E3wo*#_n9L+N~ z{epB5M5Rcy}~Pr1`F$M=q6PqndEWv?$k zt-`do-Gjc42srotmw9U4<~4Nid%Rls>-OmQpgwZ#*eMk`PdEQfs6u-VaO0q?U9?d3hU*0ZpS#B3qyo!g;R)YxTLa-- z+7Ay-Pz-%>;5#PM$ot#Bnj>kMzM}O%@YS{DpX6zKHt`KLwVfOY%M;3F*I-D6deJ^x zBv%j4a6r@l&G(b(M}s3Xlj#_bP!Y!C1tQ{DZ3s__a{*{qsDQk3LnU%Xj{=9S?A6qNnw}S_5 zkV_U`U%5!6^R}L5jPo?!jHXNW5G88v`*ow9BYB>;6_M@^C`+coL0b62w}{mCIh0O9 zH>Wk?-D~*)Uy`{uzXQ43h_*u03^Bu25`M}w4TvcyKznY2Tzz6_?NJ^Ea@IG~@m0%z zQHch!6y<|o4#-}e_p;ft(xqp-1>EgD?#j~esA)(Q^AN%3Z{l=Uge$N9!xE#YnK~tO zm2wqv#mvgdPi}HgJYz%b5?c`x3Fz2?F7}ywqgWsET_4^i($Pa$0T5N#<~Npa2#qC| zNLNVoEDy-{H)Gw56VksSb_-ZUR*?s=8C;A@zV!=wbr({gm`1_IBT9nM4|uA5hDafMVYEwbekbE=nluoTYP?Z1^<9 zg{u<*hkKgpCwgDj-*_}dR%s+PQ{rGz!Yi}Ynotv~A{~Gb0^C{dw;cxcC>@q!7udR& zplY0L|Bk^n9XjN+Mj?9G0B3FnUuWC7WAa~kH5Gq+{;$ml_S)bG73EbV`u_c1GzOYX zfeD{5cB}Zk;>eaE5+aV3Hz$D#>fS5}_iK{7-qy-ts>X}@xFObQm2OEZQNfyGjBdiy z#BBRhOxN?o8`Wsm*(a!-+7t(I07x-94gC_u8^srzRvvw$E zCf6o!7wKRZ&vaSs;ObX=)exY$a+8sxl_jh29Bl(I5djvef~A?2xjWTRit}iC#|p28 zZyQ8ul)&Dbc>FOM@v;DkN35Q8bNttI(4Kqu*99cwfFkqsVH^Pe#YX9?ta3ctD6aBXs);3&8Lw_A=T3M=>I z_U}P2_de8===X&C^px98M;zGMu(L{u6VzQ3ruom&h_UcrzC@sKVi;YuBjQt{%gGp+ zOrfy#GN?J86RcNgH>?9=q9Ipsvx`NJq&k<+St`=ySj=^;ViVux9C2)2`7QOnV8Z`8&w9~dS_EKUX z$HXM^p}VN@;}h-usO)?ob|nNgOH*dI5pw-_uMq;Tf%mA%v;dR12;%|yfk^+Q)DRFq z)~n*&LrJD?$FI3BYW-;jwGCT=eBNL^<3qOP_uG+b!g{wK_m5cgW@axw1vBg(1?2JD zwR~e7U+uBAqnxojnwptg{<$IN#SMG(QSRs&A9-?4Qq; zm1+bD&tJWHv6EBLcnbuDMlSaiY3@h9IpgCF+D3;n#Dge{EIm6-xqjY@`CzsDY^6b* zV(?t3aRE4vGg?%*lNH(At$RA0KlaCUi22>kXqeZ3J`8-Op+G zhDfS}iJ#=FknV#S6-N+iss+et+L|J2*kcPJ^!+b0BAB~1=)c2p|92k|U7#H0a#CkF z!%*9I>*cy-Nl@@2va0_-%vyEz6VYY|3w2bi3TcN*CrWGh(|oe=LE{y|xHEfwl~G0# z_Z9oD3|={zeqSU0O-17Avm3liZ~q?`Kq38#v-zEML|TM(SLm*}X)Od45D#F=~wQV4muS*2>Uxr&Zb6!E{to{_td_n9@6E0jJ&zWi#T5 zZc*-OAiablnIG^TN||Tz`C0iH88biS98j#6zQ6P#iyvFsp=JhYX6I1+eMCWn;;88K;cmfkv#FnC=J(MhM#(En6HGp5@7#AbR7`OmZ z#S@XC@gE6_#Se(l)XWw=(H8y@IEhsCF|PdwAbzSCPppV(vj26kfDLX1ZzLKnR^ulp z0vgFH`>(Bw9?)KBKlCbI@?_n*54b?IC;pF-NWCxwo%$dd(wGX7h8mql$}^M%98Y1Y z`#RwVrYD=s2BR%A=+HiWA-xPxcd^1>$HG+g#;5qAUv0QjIb6K}t`JK+F=B~S*PP(f z-y#6SooFxde`24N8OXo{{jmb}CnCGDIbj381nBk&|089cF0i#cfiq3_vFuq6QMl$;@ zrC&i@A^a6D^et*W(gDhS*eXmBCTC%E{Mduli5-}unZv~Dhb{(A+%-o#q@}ziq@*xW zaOAcbJ2_G`4!G~dubJrEbwCE2nFHLm5^3VSGXsC+0L$NlYe{EBSgvsOG`F;V^c zfUt`nz7L!huN-;P*Py(q=1_4G;q;ZT(FEE@0#`A(qw8^FsDNVfBb^v?rN+1nl9dsEaC-nL zUwZ@XeXWBlpvFF{ab94P7qq(h`5Op@z8qoux2IZ3mp#wEi26EMD$iTvX+*WV{Z#|+ z2oM??5CtjvUj4KTcS~y9jk0?P^)((iI5$L8m5cRMD}mtAHp_L@ftbMr`6|OT*@58% zB}R{^%F{|MP?-GRgf4H?HpsRXL6#Zfygo7B^f*GlLvO7a+KmHf<>bvMTO}D*v7<%M zZh`T6R56jEoAAp%V#nf|^;e6|8kSvXY5p%!9qIBPeFbJ?|4sYKcH^X!C}>9NDUqS< zR7q86i~JB5$BRYOC3;t1tck+7S*;w@pC87G{#<~=Gx~wu^1XVFqs2nnkRu^N6mGat z-H@K;0Ws!u6QhQ`sB8;YvXt5EN&s8{b(OAXoiXf-8aZEDdp-%Vkt#))AoI8+akk9(2?JPs%}z>5hF+kB(_GOE@{vvB29}h z9FR1!Af9ae0lPv1>Z4%nJZfd0tFh}nMRS zh8?5B04jZc!+(QZ&I_Q?5+*3a%Cxs4b9?1klCIK&IA7+b@1Z6{!_t}7&-HRJI!P%V z*(AfJ(vCQm=l+PF&fqN&QW6Swix~a#G6_-Hz z*XO++=xNCQhVSx;!m@qO|C_*~JC6 zirn9MI20)AB#EqVP81TAP5+xOGYKuWl%3V61|(4G61s`vj1B{eZcoI zXNO`>?JtNfF&Y#Vcx%P;{gWV3P9gl}sf*%LtkMpkj~1>Y*J`y*&IUwH8zljswREe< zTAy7zTimf&pj6a-xu%bz(4-(L1ivtt`BLN+Rto)@GtNA1^dS@iQ|p>YJ$4wWd9wTp z3M~4I``R=yN{Me#rPTyeGwMhLR_#pq>j#Y=b`DSrjQbohk<1Aa$5>ath-5q+5>!NhGtIxQ93P1F{2bX(SKm8gO!4aRWyF~YaUSk@A*fz;km;F~; zvT!LZPHFE){0ZT_r_O?sv_3!J43F%?hIYXamq;QSLT{ozcW|QO1P!D|hR0 zoS#wbh+AGDQ7bBLTE*Qu$PiHc0bxZujjo+dxsPh&Hy?O=m;C6>Wv$|<7nN|V>Vowr zc1oD0mqG`T&eY+_Jz1KRKA|`IB(ZSYSzPrW+Q}2H1yXdXcqEynxRFW?Y8dsf%%-y+ zoSg`PSy-~z)v9&H7i`?6a!&HSeIxVaz+~$@I{m+xZyGO>=aDr}uD2_ac!h2}>2A}r zD$3|U#6LJ`kIwI2s|44j>5~&tcD5QzTKB#0LfU{Sl5AiuBiLJOK}0rlrg{DXe8=L> zvIDj%Q!b2gH5j3l-I_=*DBn&qrvJF=Mv{{^$v5%scRaiay|U>nRl|_vjI`1MgQ&}ZE8&#e#|BK2Tu>c9Le3!tv*N*KoSB+DIM~@&Im3z89Up0*~DNh-7~=bw9eKUlcyz zmHXs(N#$VZLb7zi_2iS4Ag4?tr=Yf5JfB#gFQ1>3I`J#0H-1?}iNw1SRGKu253-_~f+nKJG z39f-!aGtdc&_{gRl3yr`b?h^lbeY-pZ-T!S`vDEi*7fEhr@1WbZU%x<>wAN(byWJe zeQ69ZdvD-?)3nh8JYAZ&JcWU-=&Vbjt;dd*`n@P!lth}PWM)(iRgrQfZYs_f_=L1F zV)Iirp1%mJPUtjxTtugZfpz zsP~Ye-tMQM4pZW{KS?yg4cU|q;L~58#~|pL8EobrTssHMDFyidHp;_>CuF1&?~((2 z-_kczmy)l_zHEs+Gl4Iw{TxD8&=2ulPk{RQtXQy{jo)~YG@L!uqUq_C=wavcV!pa0~+E^Iee2(yyC|Yj>CW zp~Txg?zxM6#;rLM*GQ>(^-_76ze4U0Yg_D6-!J7_>3EnV22My?2WgO0QUE}>TgFm# z!uJ9918K$L;<#Yxy@Tyazl35D3E-mCz(hM5!9s?+Dt)UaT?}wJ|pzY{fUkB3H z%b&PAI&!(Qx*PW*zJpmZ{b=LV^W~oLD;T8!Lev@2VFVrcdOzhC^^)vo&OC2wKON@! zrX%KXuZWS9xdGUNr%~!0OlS{?4Cd&~z%Q3_m(n6V=KXrF+1XW7ZnnUoyo6CoIKRQ- z7m01+R+>k_cYR@BU;mS>#_vwOGlM)POkUtBpq7iAo7<-MbOs!3>QJY+yVw`)PG5{R zT$F*pwPAx=0@7d6l1#Hy7BuQ!sE4wI`w8;M%!;tq8s;gh$RM_n3iDNZ?_5)LbH#Ra zdtZwFEer6IEn;HUFz^_guBWvpyN9L7%5WFZhfLvJ;az4!{&a#yfsBt;G423y3nykboSb zHG|5G+np8ww-|wOL{LdPI8eWS`pcXQAmI9b+qpm8(2jxU0le+{xbGYWkRm)QsEeJJdOn;yKT_f;^K}Is!&u~6D0-C9tlqzB z+m2pd{2n`tOPI~z2I<$iDu(o)&Sic(3m6VY)mS1Z-8q`Q>U49Z{s?6}TDa%4`VyK_ z!48ZEBa~;Fos%IOY!PR3402~25mYbmIu<(gI@HO-+V{M6YsWrS)IDJc25Drnh_ot4 zRfa^jcdEHrin*JmBZBgJ5ss$eG5PB`B=~g>Ug=KXu_H>^^9(JRQ|{qG;#|qLho-+62eBitMUdK2Wx(73ja$Vrf?iWFdf=z#!cp!gK_qdk*4p3dBF?{GZFgVx#U6e>)$RzpJ4_TPTqTAEb{V!5XDnqHCcRy z!id$fMX^pTMh$VzUJLm13I2h4n2X`lA;x`V3j=T~_enL80!y{wD)61UP;rvorY&u_ zL3W40UFh`ES9}JC3V#aUbi(q|D^&SOcPjM-P^m!g5?CzUf{V<6&>ff8GkA6%se5jf z^&6+Ds`$MJ3|+tYg|A2Z8u*pF^A}NnGKE6$>-xWXtzzZ>HWNOJQlU=NwQJkmOJVgq zY}QIW{3A_w?iacJw?WS-1yf`OpNDC5PtpHruRm%dRAu0H-M>C0O_AYmAoaM%&Rn}X~ z30!*S1o7=cL8=)Dg5EDcS^8nbHtkCF;PhSoc3D~}u0?5K99n)V1I}lHVA$xxvlOWw zHc)pa{G%Q%Jm2>pB8aem*rK3&QOey2b+dX5X2T`p^UN2dIeUb>yT?q)HpkN(Q8&%@ z&%ij#h2t#hciwy5$;#+EmPat9DCx}aodLpjb$<;dQ(9p(>#bY{xBut4Y`e!BhL*E? zEA)v$_e$3xJP&ynrJ1Z4-qRvhP*j$0k5?`I#+q#W<|V%;aWvu2d{&9@c5a$ok}Jbm zGXOt&#r=@s?8SDJppLol(X8GwYgf`2tWE#Hgt$6m-x%E;UP7r`5WP+Nnl5?d-kUnO zYkTdR3$_%rKa*B1Ij#Z)`L^Y@BqGcnL}4}kMyX?l`G|JD#}EV z`fexazm8oZ#@6OVT*>>=LIj7-a&8D(1&5+XNtQsWFm^Uq{IW^9JQat+F&4ilUs4I8hR4&w)feTcuc)F!Ht3bsZH`kRswB_r30;Sn{ z1!OQR@I(pG@#zoeJn(dW8Ra!g@sd_q;G{xDHLO5sR?-34n9eOpfoev7T7spJ6UXb1 zDy!rRd>P6P`PhG&h)fC#AIgSIbt)Bcu^D1McdGq0vYc`8#Vfq+hWCoZ7q?l`2)+N* zIlac)RWy_qv8B(0n_#t-yp&9MNd!22pqm(QKJ_5-GER=^FRDH^pf#p^Ii^F=YcFcN zWeuoYIq@vbsA6ng<&PG4T5UA$8v_~GU9I=kDE1H1{-qwJhh3C*jZM)?meR7)7zcg zC^%_Rm%cgn=TB(COLHVr9OrtEH& zo~@>#m7h(dM|o2O8+(^XW#0u{o{z7e^u#MlA9eyMYN?((FUWE-Bpfa-vfUtQHDrZ$ zSw~anTVXI^mcYrdsnO`lXT=X~p=;rDO20^EM4R}GyghMzVXbr7@4Fb979Aazf20qgs->Oq2sH5z9KW-#^DEyGp0~Y~t%p zq92j8)KPu=B;kkOJtTeQ(|TH(tJM#*1I0% z@KZ|rT}tYcma<<*+auKv=JA4WIpgp5I0~{vy8zk1H4&@?oA@tPvbp3{WReOFBndUB zz>8JP#lUT$?h-5GH*b-(l=|HRBX#12p_&$sOJn?alOnilLLf(~-4P95AZ&g-Rh-S4 zJy0E`9PjR~?mjEq>e1VRlK+l~DlMe8L-7vt@T=oNQDeGshvZv8&r=Z`O)5mscu*NcH%MjYrg@upJ{O-W777I_&e zu(jdEf~OJV?l`g)i5rl>q96BEWfacjbj6C|m~$tMld~IgmQ&=DLLZN0Gq{NHzJ1TA zYwTple+}lj>Mz2SE?%Z!iF&e+>{tW0^WYMjarO_&44am1(foW}Akf+|mr-WvoEA8B zuyNGs;M}78kI0|9lkw#iwg)8!2pJpwH`k#cz+nwrqb0s{1a#Q2cBa_zcwl|_l;DQm zcat29M*>^WP?%yN&HM2t3%n}41W|ne;nMzclPgE-k;ON~o*hCz5S3*<)s6!o+_mRs z{fTXXSFmyOLQR9u!CL@SH*+k_*2L6hgdCv|)m-=bmbE0-x=KD0vcWyh2c(0N5WOZq zs$bgCff|^4MNE+!x`iv*IIWnFp7R z`Hf%qUo=>Y4VAY|ep<5N7|!sMwBw7m`z z>mf?Jw4;|U6@uLtOu83)nGGJU&JmO!-lo==>aD`ehhx%_JKX@>oaNak#JdTDfb=Eb zl}TdtkS`~7AJ!*aZJ_$4Lk6RBg0GP@_kz3!8h1Vty)VOlM~d(-4ZH4$+$ltbKHC~{81EemW-Ql=fgb=+%xl*?YjV#5~OU-l)c8ftqXRS+uru|_p=I}{*B$!uhM#U-(elvl1F+M> zj_{Ssdybs=&Y?btyEpz_(q@8ae32}s0)bU@`E1W@gawCo;)#lhR7TJ9tt9&i4F@5^;TMI zDLQ&zds9BAe`vnJZ@tB!4anJR{RVM`e3SL^+x8j`poCLP6ku^IDUGyVJ$r(2!k6eY z_j1DPhnsKB*eNqSC7VF#}>!?8R59;Suedpjcw3cCK8IrtTLT5rw&lroQra*9U>S^{SMk zEld}hnDeuSnQvdorXVxkQ=0lvuUZ#uU62(%17;mEipuYrhY@ zM2o}Wu@rNpY{?C(o`dBz~2 zvLlI{pu6%Kv;*QX=!cs`iuTm*RPKU1#sc^D4v-QCp>IVxX>7^R#{*MVyDUjje7SGw zj+%&$|5**PfiHN0l~#;lricY4_wGc`1N`{zd1DB!(;Me1I;Lr$Y=_KyFSF95IKrV#)3 zIIF0MKXew)Gybg*ygY(l;gu=Fqoud~&KoT=6Vh$UZxb;;xK&#$J{JlIw<_l`ywYw?u&^K(ldWh2X+{Cy%D^ zErhH>9?j?#?!?*F79pN|>e??1Nc<5a!$vwAHiv9A`Heu*$nxX4sNqc!O#2dF8lu6* zR+4pdDb{-XxRjmd_%lR)(L1*e1Jezc50y;u81&DbC^s=D76QNCyV#%yA1(2|Qrzrr zpmDPnSrxsd<(VDJ#UA||aWo=@-um_>oFkMBKIM$a=~CcHKCNnC(>nDz6NZ~5p2ZUc zM|aHtQ1jC-BsNqFF|WndjRa;$4=mqR58FBR%CI0ET zsm)Yq6x~8*0ZlGW;m82r=yz>n9b!+3{l#X#>2xj=^f71mDDlk#sEJgF(v?YVz! z`K?^f-8$qFJ*(`GdDPgaPh@|x@A}Js-kW-(6C&eRB^3|U*kg<~XSR)~{_GcpWvh8+AJBJK3# zQ5iWvlWC*L}I_D`~CV>Q$z4-3mw9vRXVKoc1Fe(nahfq!K$2I=h zRP>})5-xLl50RNEh&bi^)kw?ZWTn9>0)_E}dRDSz+9*y{Cc}sCI^WXIjXdawn{%3h zsXoJha}BFeEkacxv6TeApGZNVhSyOv$u~!>AqG-QZrh1Uvh5VT5?_&{4s{6-|BU7Yd=>b++ zrdFwsQjalJdL9q`6f-+UD?+h{NrW;FvqB`s^pJ5k&BTRT#)lr^&@y){s24 zHXCcx#Gc-^6u%!FFaKTdhvY52z*&MUt!77B_gx;QtB zee3FA2GNmqkXv)kmd-#CB5+)^a~*YX9J%JVv{x2^Jo0io4<&*YpKZST#=`I2KqWzL zV~@V8X44jdepvD_)3U=QOe0|hwecs=6C&tsIkk-s&$VmNJMTZBlW*&Z>t(?Ulzv+z z7FS}I+-{ElJ-_FXGin6*J?~6Ly8lo|V6-S%?1M!AO{*ovwj;r&VG*xS4IzIf6(&yY=llo z|5U!Wfk=m2W{uigLgJP9%q_I3>b6BRe6g;GHhcfpU$8z_6#Rtuc<+H><(pzZX;C(k zYZFb~=^oQ)ZS1^(9rv2r8$aPzf`j(tSQvk52+d1ARe9WnY~~$gi+^iQp;z_@?JBES z;CXZDp{$G1(DUy~LLbkpq_> z-{;iKrO&%pj-wDw!N5DYfG{WdrSSAOB->Jt9i9VvJqRBm=uR??SuFzVBv=pj*c$as zKE2F|b4r}ySQdA#*XQd_JZq9o9F68=;7IFy5l_*<|FOrB@Z%#|!!+|Lh1AT)G}jO; zaS|at`BClK2Cp%yN|=VzQ){c*5%)&zD_@+cA(FVv=6B$dA97^wIvbt=N@6c zs&;_XXI64%isnMI_6nxM2w!i! z08@9cSSzT3t@CD0NUfANlaF~VQ0}lG`QSs4U*THmB3aSz{9YY8g)sULf`&X2P5QNo zU})AukJife!O#ZaJFxD+#-3T}lszLcl?)Sk4e35M>?aHwAgklaDqT6xFd za$%JR(W!L^b)ChQp7pW(m7wN_L%1g@P8N4yZ3J<_f_iTPuO+!OGDD`~P8U!Vdz_g`IN#&ysk1%*luc2&NMuySG z>k!W4Ds%0CgVz~d@88#InlD%pT6dT)xGcBoE%{rxyg)a91jo`lwM6gC)3bf?6CU$J zus%$%5`V?|xq5Ms2LIal&o@+=!%?N=SO4d^B@fR$8+tlN|#%DkIQsi z|K8s|nlM2@-Qa3qu8Qvpis!NI;^LDXg-H3knKkPyXbiF-*&J#3<2q5{dPUj-IDi%D zJF@Z=1^C6%63(yZeQRRB%TBEYxqtF+98usWT8gWZazZ_?S|d%k7qmX}ZjQk5uEF0h z#j~f7@*cs>D~rAjS?2}#(Ldtz#zI~>QnYNAqQkTJ+Borerwk1Xr${>I_2qCFhCjL; zbH2TJ86rVAajhYYs6RS??K++bn#1l5KDnQi_FU$O&xyfx(4z%&M~Gdbc}FRwJs*L4NjH9Icfp zmIlv5$WW|jia~dz2s5oEohRAhZ~F8e$oW-nRQqWLy}6$gF>AKSkg^C0uPzJS$ukidnwUp`M&kpP9ZfB~5G z1EeuPt+8Ci+WR4?O70RPtpX-njB9vRzIhkC)J^rmyr{c_NM0LbcRA_~ijTlssLXuv z10mmVKoQm>%(=Zd&^5_xPm~Z*y=8X5oAhi5yUXY4w73aYBDXrbl(n8gZv@}k`TS`3 z?3pKRSV3!L(oYg?Vqd~bCU7dA!m}YIwlfN7@78bq_3B3oZTfNHK1%bqME)@38J_I z<%&x9xID@JwM)RbhQ^onl8X_0$$Oj;`ZqYC6uxFu1x@<9Zdm{~C;yf59@4Q;K8Y=L zG$A>Ekl#{9u-+U1U|De5WB@e&)`-2EGr$e=EGMdn15Xj1M=g_O7l!sn<;bIz1auQi zWS3)qRk_1>-sU{j2ntG^-QJp_4pHiGSmq{ceR@1VcS24OPhKr}ztPU5&N4fcJvp~* zXIb~`Ma+u|$CUt2xBWVzc)?w%OuUFrnr8v+j%vwgkaH*WnZ)LSSqXk<9G*)EMHCnUNhoN3+w2e6y$@iqV%3|ht2;CAu_UWkfvs^=5|D> zt0|5jJ`=b1wlBKwjb9J;&iPM1eX|C(G|7JNf(kn(E9{mtYdt5-t0jI8`Fe(u-yR(8 z)ypz=CF^_`6%{@Z*PFN7kr2{P*u_gI-d}G1&d{LI`gZRvQBwQQf|x@O1l=g4@PxyvhM5n%ZVDzY;X3H>GyYYF*w@STR7!Dh@b;@&$m zvdh~vGXzQHnc_tLcePqsl`o6ezt1ZkCCNg54);PS?uhw3a8W3-P0Kv`SrQ{4i+)4SyHnXzJfD%7rkl7kK6?Z`~J_Uxe`_lln_&%ft%k#hI6#f1zDcG^1KRr1z}>jJkVwA-4%c&8xK-%fZm#OyI7i?ue#bzT5t8hn5dQ7%KnB7OE()(^ z^3QR#z^{55B;4^2C9xD`?>ZS#@F4|cCXz}y{y@PfWYzqSc=iDi*eyOCoR9_-0R7pB z8ay&$@6oi08kmYX56QycRKl`<-a-vH@!%fe2wP%Ht-w!g>3+A>IdG26a13mxciFT& zwLreZ3UpPx=0Lp23ezxw=RACUabdCX(MtC8u{A2-Dna9eXJGz+21Fp<*(V4`!`M9G zL5IS)>R_jS@d*;_Swu}&+<5IM3pibluj{SwSvoJ;P8SP8f7S2`v$-mDaJTKVyCX5X z9JSnG#WfkTVJpy=Uar_h1!)KyjZw?Vk@kb9z&ad7lJyfl(;k!haxiIgA8?%8*4~?Y z!{r~Wd_3roMhM2HX~8_$H&Q!kO`fe=wLFj0AKs-7$91Qv+*GFxgcI*Qoy+)o{m!)z;p0(?DL$BI!NJ7=?PTY zRPjk-ox5X05>M9060G0*1bv3wO`h+93P}*~R(PCWb&`rt{xtzpZ6n{^kL?LOdb^8q z+H|%Qb?!CDnEkBf;AuCYF^xscKjorIkKEyv5L+60bOCcl@k7H7aYC*@8j3-9c8ddUNUqzN4eNX?qnI$ckz zn)a?LJ1>l_B~9A!bb+UUt_J=QwiL05^_dc$B16W5zb`K#T=%~dXE+GJ_N9DV`Y^U- z^?OwCH}2rd2zs#OWe-VPLon5bbW(X5yV@|pA%ES`j43-tSWHXz=NXbP{WZa4sqLKh z*-Nxpdx+cjKsK#j!BA6*RBMu~F?fC;>dcm8hmBM(n|$XHoNcmyF%#VGG8pM1i(t<; zwt3swuR|Jo5GqB+gg1H7I-UPGYYtob>va}ZB#=j#Y!Lo;c;FEpe{0}FBQ+lID```n zLzCQSv#NPMjv=anG&yGmw)5G=aFjzTlYGSHw6A+NVBjnnaF3Db2{~C!#`&J?dhml#fCC* zoW+jqK+#O{>|%J4mE^-;Ppu_ChriDh8R&;#90m4RliWt zH@uBqf}oHEVm8nj7|Hp<)Q-uyyC@O0;i=$D;m=+*9!>#2y0qMES$Jz!xoWM^fx2AP zQw%{3y9-I-j}-$B3>|AmY~d%?>*B44`51EaRC-7P_az^xFoW62iNmu2OptJux<{BnhJyYFUeA`q7mMHUj*Vk` z_`5`xb>&~W%?}AgHD2_DzbiCutR%=7Gc&t;rC=*J4nqLOnYo? zoFzV^#|u)wto3p6T^QJ*?~OK%$cqIoW@7C-}{~s)siAoICLpA0pApd1)R5JgL>cP1W)%2Bh%UT}b z9&La5;D8LfMr(gL1)K)gN{z=9Pvvi-)|}&jzX5yT?y{XR?&g*)=dbSCq^=c6jKa_C zHsbUZ<))GyfjlM5kTsJz*vcRn550+qQ75WruZhwgk3UWkAZU)0frH13uR=Om)2#HIc>CH^vhPlt?LUb10CYDEevw2JBE!U3*6* z0V|b&D8F2xI-ik^L3l8;6iGd)f9&kkZ%4~JJDfXOXkJSWw6~>hP1wiDN6^uqN(vua zNivmqXJi3Nsy@XNNAQnPpvE=`h!K&*4mdXEe;6`wqEA?!(mfkQetw}t%D)U!Pd-YCs+V?KkOgg#r3HkpSRW2ET%x<1^2vcbU zqO;FFC$SdXBm}Cckjl8X=Mf%{CqA~boWRLFteaJMts*UI1^77bU_I*eSTDVHdX_7^ z{p1_1hfc(AmHz&3xm54TdmQ2qoh-q_-pey9k11SK&hSvpG)W`^w5C=F_lUwpSo}f& zro+}LKw=_fg@H#0WOjhe__yXUNWU7td$@@R+Y?d7nAWY68GH#>R-OdtCaiuh#XJXB zE@UoO<#^td1;;!OY`f5VA=kDJ6DRqXRglm5g=eVgr*ts$w-Uvni<8>Dh-_H^kQq{G ztK{SAD2Du*G|M)l!Y5v?@Y&m0yHHEWyz-sEcLM##6X`o-lRSD(WfxJVDUBIoKGy7+ z8w(b5z9=E)28}*0)Vt?PE_84UuG71Iq(8JJ9=LdJSIS~s^>6lyismYF@W7jf4*=0I z{HF*e*TG3k-e+bxzEU?Me>e*S`cz&^g9bIPEnXnuuNQX-Gyh)qBpZ1l97J{%Zk8KpXlqw8zq#%6Rmn-h0}>Eu(DL+TgWRu-b<1GeUhYe;;%GbM{NvWv))L?k zp4@coLOI?zVO4l{N5DXB)q0)G>UqZER(>8Sj4u8uJ)++ltw8Jt8K!NWYk9c3FD*(- zX`qr1GT<5U`{(L&rr*h7IkQ<4J|ygnS9R72;{W4hyiQFo>*Dd>#9E1KG2^8EH!G2o z&o4{%uX47F*;3XLKV2$LU+t}7t$D=H?|4}j(SD`0w}5teStmUo{*)#<6=V91BCzM* zy*n(6;h_SHU}X-xOXCksu2af^Svj>;*|rb`c}*w0%buCs*6rFew6siYqUuKM5$mlt z=lqINck!`!+gz08N}wk*yR2q3bh4MP@p&v%>&bswu9L-kwb?HeyWla>D=KU37d+H3 z{tSJT*U3`=0#Vo&Mr>#_m3C)|U=(X83l0A}tcFz!4po2|if_CSpwts+=0Lfh^GxdL z2*f`tQk`QRF9*lZFM5B0!A%02gRogE@%g7*_dofHg7yqT8IA>aOtBt*FM;Rhu`|9J zkXp{JJ40uJFbbGbP26)8&K^h0oQPEY9lV`9FS({5RSbTFj!8Xy^1FuHe}`^RIwIm^ z7-#rRd7IOZ_uu(t4DiiNA;pCHsw&oF3vF%o6Uam@wcPW+KTLW+UH@sP^?8LQ1_8V~ z6Ec+4)L{J$oP3qy|JD*%&Lp~j^#Kqg&Ug54roh)aTK^6mI2{fq|Mq@;;Kpuj^lMs_ zN=C}HB;I^RtN&eB;KzxDH<`r1Z-ESD!T+!6nyKrYm8lB|aEi3+D{ zU9Mu#QT5&tE_!SxHb?5}7h?n`Nbu(A9S2S(e8`kcBmqT<4-N>VvBM`f->M6#G{=DS z4IBU5bfTWu=)SgHX=Z9++z(dCKlqn*1Oi1lYWbZ~wO(I``HkKqz|er+RSK#CgY3V? zL_W_HlDQdr(%0mr@J_V{TjGGtQ1YKXpw@UUv!ZZ!eRQG%a+|tX$jlp9oWz%S_1xyH zaac7jkiO!y%mffucU1dtl{B9FCL~l($pg8EssGpV2A^xjXqga5|MmY3_M5HGnr4mD z^%(x;>;YTAyHK+p{#nRn9mtF~ER2c2nHc8-$k!OHF962VH~en{)bmDNUUcbP_gtdQ zz;ZJujpwFDaa4oA>n#)i`_B?lh*4_t6o5~PAN;okoX?<*$Ch8UG>`88|HF~dc&%es zdfkWF|Mqd}ZF_LMv(-pUomeuedi}HnKp&V`GYbB>)Zt_VwE6Adqo~jGj%I&(?ti!d z_{2C(xTZKvI8d09VXT!fy6-`bo=X0MX^n!WM;1+DjKB~LZ-k?|u{@LwF9ILx$Q*Mi z6r*miz8=mqhT$l`9>?p`P$0dud{Ydq1|9|kRzqF3*xPQ^DOEp#3fPM4>Es(;&>S0; z1NrR^gloD%em+Wor#R7Z?0T#wBM!W4>>zz{{;I+%oq)OUNOhmJJPx2mTT^F$AhGJX z2HVt!W8}>Sq`qss4ns7&Zx@#zMG9C-80~m(ymv7tNTCZDq6kf7-_o-kns9lM*h5S0 zz-tCIXmzQ9ZfUIjF8y!wMm1{*4~MHRbarWiw_eooTIcH1u;D1;Ub5J45WkEo9HUPp zl7Rl*NsvEeI$fSAv;ZW-xU)=1v@jNhtz|23@nGJKio7`?<<_?)mN^HmsOPF|=zrHp zYU%{#!^V`S6-i@-cfkM`&8jLFidm^(`=Yshc-t95D(UIfg z085cUPGBd;Wprpk7V6nEzJ~D&lBl2iv?8x;rq3>~;Hp?@(=$`({W3?lp$I0An|0*J z#n>EKO->KgWluEPO^iGwhD{J2n&FhO+4I-*sMS@$!tq_4fTw%7lLNG=6Ep5Y|Mv6S zWhtJ^V9)Ci%YUh*;?n@$!iku6#|iPN)X|@;6#>??Uq$>nGcF927Ci+gX?vU7Eekwb zs9ym)lvHS|@1urZZk4}6v{f>q|BY3lUzyz)QAqs;-8&R6<+B|AE`~PEOeZ$u`1$X{062)#zcqB6n$w*lM>8TOkg+e z*#|^w)op+qRaAL;viN?A1G`HHtc72yFj8G7_MYUC2*$tLRPc-I$+jq72c_9iE#HEV zj@-!qY@v;%3Iy;ReF91WzN7E-v`{&N(K7=Vsjr-Iqtv#b^Vj~;GrcsnEJ7`neCL1n z1}vI)aREAn3g^2BTB@fbns1M=N^^t(<;qMy>Y5RH8wTA^*mJMxA3wcK9d$!_9(_Qh zQqAzdne?~30*=H3(UkPtr=Nq#o@0O_LO zv(`H!tQtriYG}@_&>;mk@Y%YGxY}nBrU)@v$kLnK#7B4_TZ+hwnGma?aMMCXx z_nu5EKC=~Y!gZnEAo_SC28^;%hVkPE$qO4_N?Xb=y20Tz)c?j6SiCr<>-apc)SgWH z<~Pf*nWi8TfJaEY*7*CU2Lq$D7qHbYs%+xbRsOw(?mhF7Q7~D)v@+nAL2tb7OsXu3 zRM-bz`jN40Z&v$$HLTp2d{HJ5*(6b1uaaMLbBmTQPDCCi;BBdHhN!8WX&_~|x;vDCn^(L>8yxlz-KD#( zbS{{8b|ZZ%KLd*-RzK0YrRjWFLUUY9tP?0%a;qj%UG)aW8SPSz_$VCmT3?B|>vT4X zAhGZ@`@LT*$IzUEXDymQ?nd|Zei6?8j?SLz(){e078A+&^rly?1l8(<2cB2`QLJ^r zL953WMDJeL)Zyn61(VI{T&_7*K6pe)o!GdV-89M|+7!d3HgDSas~PekK~u%#<`x^( zWR0_DBP8duzQVv1S6;NS9FBVm!8!M^j_6>t({d%(BNqR!k#_+)_53$_ugi5S+_!;H z@UyYf?s!2T``AeJ-GApm^YQo(8MD!{$Phuw9mL$@z@zhC3>3W&s-veahS=~RNM{e| zMm9Q{lHUQ9!1Dh0PdLX<5_|mVkAuk(ug+w4vJ+C7AbC5rxMO()zh%H&R_| z^x8v^_}3pDgswtzaBcg^!^JfNBh^2cZp~D@nQbqiY>sHUF{~)sX%g=McfCFZKI{O& z$2>(eiFXID|4}~(&GM3P$NWJfBX29XkvO<_g*dfqV&h_Uo#pdi&0W;JUURH+zl~rzl>1p^aqHA zAD!6RmojKZYYFZe{XTIO`1_Le*x)UO=H#*LmgtPb(To1Bns|-8nz3Ln^wGiNavRO~ z5ZdO-v6XF;D*#@rXs-PJVRD5#SZ5EsANGM?YBd6p2mdvXS5bjZ`_~Gg_+s7^pb2H79Z>-46d_MW0a`2s#zG?%+ybUJ zzkBTP=|UFSm7IDkO>a+DZI&dR`mX_@Z_d*iu~>k@`}1KAC?uC$LV=R7o~wQ{d|_?t zV4I3>hsW~5MLE9Kt>E=^U)LkiOTaGBghMJ-xsrM3@Rcr((3> z8Zjq%H-|$?p!#kte> z*=seP(X2`Qc%oJvbiyl&{nFi&>w--g@s2_2#d+qU(7mz`qvv6>R)QOrRkL(Xf@P`d zwOq*8^qpLz&XYL7c+D{S^Ku^`I}P~I0N!E!5<>7`oMjRRp)-k`@j+z9aFDaq4?ftT zNBW_mD)Z6?>UO$wTYGQ_$hyj4j>y?!<8i+0=Cg3G3aXc;;lPV^U>8-<}>fY~R7)o-a8)PWy?ot6M0Re%bO9^R_&JmOb z0i{Mj2@$Cok&ql(BqfI)Lb^uUA>PCD`Tkz6i;F*C4rli4bML*^TK8I@l%l{wC%v6@ zB^gIfk`D~7+XHNR8atOYcFZ`D&!tg^#_4dHMh27Cn@2-h2DojuDg1S6)ww_Fs3H_4 z$~%e#xg>G?)TM&>Aq@v_Yw>U*$}Wl`m`vi_(E>YL}lAALQM$s^r-4NPbwa{iyZzd=R7M8Yq%@h>)tVv4)S$_Gyx@ zs0$tSlqxkYg6HXM9;A+))^`U7nk|8)zVWEn?DD;SqHzJybSn*aR@zl{|IyIH<*9vj zx{pJVe&0}s0Tcz*MJvY_|3`gT?^mZSS-D-6u}w8+Yj^-hN#9IOYgSIa$3#$s=zLQzlB4eZ zjG|vv3F?^yTQw6LRM7c3z>cJR>QwbW+Q&GjcP=kKHW&=L7V^Co*~qcIAjKdhS6rVm z;$POZ79>$d+8M)rGzdcy+(fw+@@cCboUpM^Zd=tHo#CAY{yYhEu3RK5FG+8?%2)0+ zNd@azv!9$bByXJV@zs)-tA0021@~p&JHsuFSQ7IZI|%>@sg<>ks<;*qoBp3pYS96< z{duxflcY*mQ{0tWWd#Op_fv7m?iczx)dH9L^-jt|nS_JIzVliNU5$_m@C6zC{LYX@ zhoi@UuR==(kVLg_KfS_}xsn)H{s9!J!B- z=AQ($CfF!5-nIj++TGKNM*^-=LxQJjj2SOzI0Z1%Z^ONaCp$&oOz);L!~xr8V9fYb zI~LUcsHQzv)D8RoLFngoV0||8TaDblDyW1`>SaruAP?uuI|tUzZBuuvNS4-6!%^F? z`pwG)JxMHT7|9hFs`Ru;Ur>2?fAjo0tTN`Z$9Z=up|jSI=i1+*z+7YN39GE^ zUwKR~G^^3#UBN|A{^+-!!FOkPLLPK;{K@pK1WCren)Zn6<^7kX-_v4vJdxeDX^lFJ?yqgQ+F^yCd|cvb(| zYwnX>Cgm6TOx**L-+cYE&*Hvfv169Rrec6Tj+v^VVEJn8>eEAsgeNAP7@ZxNNP-g% zQcFg-inm^2EUXennRkQ&lYj6dz-+_**re-+S>GQH#r=#K`Yl4#oUdTcC>!(TtpoZt ztD!rCbj!Qts{Y{-4DGw1pzh8Kl=0S6W(cLbItIv=x_j~F+9R#kqppht)klqU7PAvr zmjA!O@g)TYM+lJN%>!YZ&)D>_VAjTsGG5-%idLOr%0Ia?mlK4u;nOLl+%IKj-=-2_mU7zCV%3$z#xb1_S113=fU=Edke1@ z1tl_hXgTdl_!qqSd@jT}`xMtrG(8sQX+34cC2#JBLbpkDxHIIZbg?C`s%52dmGqmY zs8pxERPoT!#_IV~q$AWwwCBn`PP;1d_SF>{zln>kZ+VJHjrS?@lkJ~^;?(FbUE!*v zVG1xP(07>+O*<<^FzYvZf~-jZbArUIS$a!t0i;gId^HYXze zwpW8>LF7KPvO972-K?R?Ex%@43TUYoS$JcI}#X3Mz z{pXtO1?oZc3c-#eqb$*qa6((~|Eo`Ua8iMt+DIRf=&vMOSnAdK;qW_uRL+jw*)4Kp z2{VmmOuF~}c)p0mi<6GFbF9Ydz)tLbA3hDwiwpPWZw4|aLf$mJ8?_Sbv^!S@v#_d0h{%pFw+zSm2LX@zb89ftKsw?0%d(jPUHf(cP#0Y7pPFOIn~}B zTVKDwqDC>(b2y24d>Q!ufR&-s%5m$Be2OUwh|1vxj#tL}nHt}#E1uIGud`!1j|OPl zKKwiD6db+PlV&y1N+}V-u5)L@SH ztX1_@er$mKX`$V(uuDE4?=A_T>B(7y)x>)Klm1V;kso$zL7fD<+@N)Gt4Y!23nNux zHuLtYY1z)_W!m__tSV8w9U6T}(e#$jjR_H~jZ?;S#|(==26wGcUPnox#JGEB?iK%9 zoVC8XN8Cj?b?{G1FRhq&&Oi-6%U{x=bMjfa(;mvQ;+hrM$_IbXPgc%3_ORM_>TW#? zNPmq!c;)C^lACx*G1(m<>iOg(!Y+l?rjt18QxyYoCy5TT=$BiTG8Qs9H=&-B&iLu!VL32`P+IZmtS*o zzbsKdQ07jmUMBw?1@8vH6v%N0HG*p^`*Gp+<&D*e3Y5s|X_ob5M~8 zaNER*vWiW*rXZ63U>7)Oa_?}(JlE4HYNBO|o>#VmPejoAKx8M*y-?8>+y4kkX~Pd2 zwbmAumMOkJ06St8cdn&WhU+djb5yW4%bWB5YGz{3oGMPw>?`=X*Cn?(d-}%xD#x??Qw+|HnFSmU@$o```?xomb^E;LyzBd&)^@8S&6IeCk?Fd%$>odj4jC1jd zjrHb7I6920uu%j!^Hwl5D@X9{-!dC`Dcx^8F1T{u`aPXG2u@s3_ivmhOBxOfhn&-L z;zq_+{&CW4xr{;`-5@9DSAOWEn0bFm!Qd|n^m<_)raDokl2m$EJY28oOE2mAvbbHf z1-%4)(M&=yN1{Fo2(>@-(K9qncY(-)m$myDb7MjP@Z&7OB7bMe??cS-M;f=MVosf+ zlZAnheUY8DFzQUgGf8n$AqHi-?e`bu19f%;Yc3)>NCDZ6_1^P>E@BJsBKuy#A(eqrI@-@t0-Y42{Yw4**l z6`YbsoWh@9_<8;9+}WiR?2tw=K!_!2!s=%oQ>ZbvU@MjzGI~4ZYlp%=M^40WWycSV z^dohTxUMqP?$M+Ae}OxGTUhA*MLIK*)l?KYI^(zw(=ItA>NAwKO7(tIRC%UgPC)Zo z#}GU50$8JDIiq+z15g~+K&orR32J#kvgVSvOKD&wxI$}16gu3$rw9qWy`WbD?FEV? z12TeXQix|@1k~FawUHBCRa9!GBcV!CVbpQ*N0F@I(_CKOe9h&+EQJ!7_DUS5UiIoz z%0h@4{lDqN@i`NCL4wXny<=sk zqbFOB-TnVRo=r;N2bx@NKM>Fx{5 zQ3yTqOC6TN(rcVr)t(?rCH$QW_?XTIro4ZfWFz06e`zR_PECyRjB-No&zIUyWfwbB zhZT&z=~#ucubGDYViXqi`2a`e-dVSAvHn)D7PnOwOg%$()Dp;P~?OKEj|82at2 z0Dv5pgb;?(Mv%cJH*eVw5)Rc63d#C&{AQF)t3isUr%ovXEG*xt;DO9RsXgcFDcSUf zOvPL4AF@4OZ&)Tg61G+IRXoG^C(8Bi7#k{2Hl01I$n32~toDk*n9(DdA3s59mMC>O z6E4Ni&5uz?pe2!697C+XkJ6OJtaGT-!SS&U+|aRx?C+O|ne z>Zg*4sx;r9P>kwX&IA?ri>FNr%Aac zT*r)$OI8S(1>XMzCgAM;{BDNt@bX@T`Olf0>Z~ynKuVZta}AcYK}$*W1jv~sea&4E zafHW`rf%0}JN2y(#xWI=$xqa%zzm%X4K>KlZ7K=gHvF)_TvM=_Z)kB@GdZLd&<7{Z0# zE4`VUcFi;m8d%v>8KC((!fFn@$c;D_r0zPvAp|l0A?MB>6(`+7pQE|eABtW_;SjcY z)zpifh){%uL&t~c32u+)R9WWGj}3vCMIPt?9(*b};YGr~*iNVK;iPg-!qP{@fs2fObxy}|9hZ=$ z{@_{7{+QEK{OPQcv_P{+ZNvs8mrS`C_kywEbS33cF<~_bM~7Zp8%W>9fN($n%UoZM zW^Non_Kgak^z_`>>devZ_w_b9Q}-?F6<&+3({52>kZ{xW0!f5OJjd9u0O**&!f)m^c; z2zr3$DrJ9tan26eQMvzrUbc`*R3{!-^=WY9B=Xr9KvlT$ zUBW!0_@A@KNhfomI8X*CEto!#pMb@Nf2*^i7$9Eg{@SHRr2O`L%3Q?qMt#utA0-86 z2kt@;_LV^6|7iiFm;etyx&xh$m2u(3&oaSdp+6^tF~cbO)dErD|Io=B!cQ8W3Mi)B zpwTcBSO${kA=^U`zjG!o>Y89Lc@u3c*(Ty+{4=4c{jzN4_YDB&|KI27j040gl$+un zioM4)%Dpa8ksx#YMUplCAW_nK>VxCACx8AYkJH!f*4Hea_Amt$FQ2~Sn?$v}^%&)z zl|V&C*?fI@<4&5Ec{7URHT8H|wLo7ONyX2aVeyaL=Oe|WM?{}NE6;uQC~uA#GV=8| z|6lKBuvz-pFvb|4zr*j4&-^>mh}WXrVZ)|ANxUe~$C`o5DZQ*cHhI9S+m_CC;hpr?6iX ziKM51y&_ArMmFa7yj= z#2XZ-QBiJ|rt9_ySkcbxs2+8tbGMZ?0F;dMK_Dg(3R*o^p5@Rv8U%*zby#FYmX^d+ zjyepEidBIEq`5Z0_GokC^fa0P4cjwL3@!C`CI|=0fK5mgoED&lP%XiA0^{TWFlp_A z$0_E3yF@HJ2SiCX?5*2VBe}F~a~U!>p2Z|ryVuE@*XFO*ZHbfcIg+4latMo8>x^lc zt5Yav4WWsy?5%KM>VLR-0Ds!{d~T)$WF(}3&4zN!xj-MEN}~utE2x6PVjbZi0fG3= z&@w@LB3SX2x`0JfTmNRhCGWMqtxJnxRCzynBJj~p8|P60^{QSjp z+3w!c(3K{TuY7eGNgDRKE8L54Nc1qw4&Q)iLCi0bIt_ZmbSHc99>^kmKZZZ1bWHol zFliG?v?NvWq_Z&YfMkBf)EM6 z;pl6~WMa|d%D2I&Pk(?M9*vIRezl9}xsKj`wlG=5t`|xl)d=%cBM6)Ce1dVOVqo<1 zx~YN81Eg-O(Br$s3!29WCemih!F!UE@vf-}fOZ1prFb?U)sNV0>3l@eN>Ifxr6B;T z@0e41U11rWAFC!r%DF&tkRNT69;eTf8cxa?a9XqDt?WTMXK-cv+zEE9ytA9I(Nca> z-l74++>kKs5mxG5C6mjpSnhuW>+Ta28q2G49iMqPUQyBq(ac%ZChPFPL&?sh&$tn# zCHDT`_(1ihvL7CP^&mt(R^F=3i2xIR`U~q2OFju zNLU=mxP(L__s%F>d%uOaL>&)yTlWrE3|;!pX{tlm=A0MBt5zsW93XairHT2Ty>bX$7Wk%PzawcbA=TH(0(c07?CvkIkyM+`>Htr; z0BG>+ZHO|9R4e_-l0^TWXs2TjXP-E6nU@yk73!?-p5}6=7{0QGeA)!b89svJY(E-k z6}(%rv49tVzc8eb@^Jo*e^rR!{=I}>?UkQ=!IzslVa@xP<()#B`1Q)FnFN@AWb(zu z3bEllCmic*Uz?YZeungB>IQZ}Dk zXMb2u%_k_j_YvM_dy6IkX!E{?AVWoJV!-~8xco`^rQGJ+^J3wT(}&5L* zO10wUUS-Sb4IvZZYIOO4`h^mT$^b9l>nXaj)ORHnCrVHBphX)}8 zbrXmGYMyy2Ew}l27jzt7G5A$lW&4JEe*gg8_o-TL9{NvYbof;ZQ>md6l?pTo=8aQ| zM-dS%x$YXHRq?5{2p+8snN6Y5;I_L~bXJipr@29<2~$-%@u|8It{ABax`VXLkgH$B zw47t8>UYBh0)RTz2Ka5E@p6%PLiQP2=%6Ra_-O%sd2>9K*sfGfEF-~eU}eCis@v6) zWB~i^K>j}->ERt7nnAgLdr^i+eplO8(z#^Qj@<$$wqq#0)!#!|v%bI~<><5&2EmaM z7EYSK6cXEo$Ug<<1dJd*-k?Fi^bkdk`X+&UM0E8QBU2(HMAE}Sbb5a{bpe0T{h&EK zDi03U1N4hdlw?VolzFq4~cOpP?$TKm~GZld4g+93t=r^F5QL3m| zQkzm!5B&M0%5pJnMO|K(5|f&#^9GorC1a+h=qsyfB2Lfcgd=~u`DhG^0 zYYQNpc=)|UUriOA{jDbZKs3(5un`|^c~YX68sh9H{EGerr-F?HL%2SPAvHt8Ggh04 z*dCnuS9ow>zKpJzVk|s*dJ@q)tzuDF6wgkrcfgL`fiW6>@*MSeE0iMzFJiW6Iad7G)t{m&DNxN>zg64$3 zmi(dH{HJ%C&ii3{u}eURTI_g%s*9)PO3`Rpu~qMv{NB6v@$arb_!}js>R#od{w^PL z<32!3Qnb1$X|A8!7d+R=s3_~vG6&3~3xV&ByN=1Jg9P5W)#sxzMBu}qY#NirmFhIV za3#cW$wZaixLBqZ&0e6Y+CdiIu!5S8mwgxuf^~_)Y=#eC&c6HKa^|A^jMGUM92JbF zTP#6{u}xeJQ4typ-w_pfGm>Wl6idqp;ty#n$65~kxP>7JVp4Ju+B~{*1uw-ta;Zts z(h^FQT$=tpuoqh=n_snKkpDG{Tkh-1atq@7dx$})nzp4f%3vip(6sQ~+qF^%f7*5B zf`joXTBLzsa4y*ni8X6Yc(_N8W>XL+t^-Mdm5A2YymsTG)+vQ23>JO`Y$C>&ktZ{q z8`4;WJgC|&Kl+w;;^`9nb1!`0~WADd&flXbgLQv{K=&}xq1buoc z|NC_E4+!`28NggeER*NvYdI111#nv@?J&#U%FNg$ecF+d87I3v*?X_2XOh z=+Io#C@ic2n7DaqjpxiMwHhg{WuF~r4%~$|u?ECb8Eau2Oy$=3-vpQ7TqC#?Ggs#J zfk8q*SvFS^M$3uU)|D`-DWZMZf0v<$Eastp7Y)QW{*7`N z-W+31WnKnJCyf0TzZ$V){8v|`W!jgKLqPalHgyP9I+E{m$`v*VR#H>N%%5bx*UoJT zInA%=Jg2v?GfTFG4baf9b*VIh8LCWU{ye`wG@6eAi5^(jYGJI>0IG zObHP=_SOwb@=-UD=%l|$G&}Bl)^3y)LNqCFRSr6*x5^g#@{UVE;@>LkKXY442c#W)Puv3q^VM>6X;EBe|j)JeByzsfNCZALep2&JubK0(eDOI9|wLiI3|=8 z;+~OIt*X+R*2A>MCB07gnBpcYp4M}wX0Lf&BGbqA z`jl^~N&Ac%c4_;<>>-y~eVBke6?`7~{Arj2M%um_C81|;*HzT>?&OrTC#$%DP|Ny7 zX8`sN;;Mq3e;tF*v7>GY>WXI98(*~tx_J)D;> zV#pD%GnW#I9Ux1?QQ@2Cqf1UF0^~B}z)>b4+Pt}?ZQBUm6JAAPuRdGp|cM*%OJ_R!D@3qzENHGi)%4-C2CYus|1FC;|um4_|#Azett?na5 z51!fs-o`ZcD&|=Bx+msN2{VZ|AFd(&%n3$)1TiN_et=7&3{kqcvj8NUbw-g_fLt@4j=&Rp3+ z*(AtUp?S-Gn4E0S@~6ykFX<}pGo;1*7={hTdDDY4lNKqgyKxnqF|{HoxdzpWH@!sZ zbzXX5QS>_JruK9Aa-eOZx;!%Fx6Y4Ap-l3!&a=VHJ!$W<9D8D)vb=toK9U5cPGk7- z8~WGDs-g+)2kmc$BXbJNUnMS-%@9BZeZG;5vjfsAp3r$k%8N(#?jd62r2ZPru;X$s zmqYw|&>=~kiiS%aEZJ&^=HDFw(C)08j)r1B{LMVu59HA0FkdSkJWjhrM)^X%{m?X^ zIa_VXk3jg{!MTRy@5=bpO{iQJW7K`q6FA`x*`p!I7rvwRy?c4;@|#$JNzfPCw%a}a zpK`dz?%v=l8lyFan`v(*qTG!Y>$SBuCjohY6)Fv?l_lTS@wvviL`+=XdG-;sVC5Y4 z2jK`n4T)Vl*SrYI1IrEx=Rb+r*vE+uHwX`a@x<5fe|CTPM(pK*GZpdzv`uqZ>tzsz zXHD_}#!L4=bx52ciXLIXQuz;W+xGA!X#H3JyKL;p3!eXb`7i@i{jF}`TR@q#4OkNX zxmVAxI>fEIiEl=}YFpuNP})Ey&*ok^)mND3|EtFZNT&Q=ehKq)s>>r~W2o7^Wqd(4VR9 z+VFQe?P3i;97773#a$D&=gPp)6uZ_Dw!RO%ZE6h!7&0Fu{@#{)%bKIkb_vtuj;3u7 ze;Y0gNzaIEB2*F&VR~q|ydpvp`Dkmd{YC^oLYd% zWX~`tf=ZZThizK+Rs#i^DLd2AgBw=szH><1;eO1}p3cciy+~b>(RHcP{ML=`f6BdiA2qi+#xQdeTjvvW6rW7<=Pha;gD@=*TXzs9G1C>II-Y+?hMKN{)2 zx(oR4{7sfu!04pZFKS~=w7&*X6R4ClEDY5R>?g*H2|Q3QE8dT*qlHtygd;7vT%4Jo zJYYK*%l4FWW(H(~wOLV160;oTFXlv_csu3S7mmBpQ1|Km*t4m5=%BZ#S$waN7mMWb z39`T7#h|mDs5+j*fR&4aC_*Qw?@=b7eT`B+YAaMDkGNTVotHd-$m~{9o8kJUg$9ob z_#H>GG5j%gnj`D|puSVk5#2B+c{7vWMXwtn{U6LQhk@^;=~pLt^i1^j_Xf>IF02HJ z4^hh$Lpae6@uLKVJ=%(e-U*mz8@?A&zTVv)=VVYsO!!&qLi$IK_b&-$ewG%>~vZ+%+iW11rjSZFsC~ye5yCkG zLy6LWamMnzztP#dvG^-B%;jQ?wQi<92C*WoWS`@o2nlL~%c z+}wWF+pU>>*A$(e<%&bM0zW1;-D2U4ss8E~WM|IyxHXgo>dJ!H^mXTzfgx|}i<5E` zhXoPO|7L8m-(;B6g9YC%fi2^#=%~l-g<43lnL#w?nY4JTu#a)Z`lA^hd#eh#-p>eB z+VpE$X(qVuA%!eCujnS03n3#eS_GtB+M_{X6I95IOw2_jX0k&pGM*qihN` za*tQ(9O)limko>A!-(csYCMyL32titCNYqSP(gA55m)6vlV|6T_&=n!s|+yo$kAz% zXJ_K5@x__Lej^mY%>c7HD~b?h-8mL{W4JE`F`+jg#BHQ00knUym!fv#>JB=QGRigqk> zq+=8`dAKxn!al(0N0J)ZL#cH;TVs?`gz-MWM`hqxdV~u0SYMWC)O!1Z`2_!35Oa#U zc8Jtsbz%>ASQ6w(J#qFn;Fw{F*^B%skguzaJkN`giy{ytX2c&NTp*0(Itvg95QdNn zbY#6!MKhj`WSz?47mWHZc1}9RCXB%@=M5@fTr@(B#&v0FS>_R=$FTZ z$XnIE#@13qJvUu8Ix*Lz6B!)u%?PUq+Nk9x;IwW-J1E1baOJt^_fZWCwK|kq;R3hQ z3U9T1`Pu`FG%UuddfmpyQll8}!UNSu$?=`T%zQPyt5XLSt9~oc)w*{1v_Di{WR1=# zPeh$jC%5yLfCpL9Q6}DxpY7So(KnsO2@n8LyO|JrJxP)IZge*N`$ThuqElc8Abr#m zcAhKmWv9r4#ovh$$*XquU2ZaJO$B;gT1%p6VJpfPwDN=Phrxt5Toc{v%tmegX<=t; zyRI6n62|&Kj-WI;RCqs1gU1Li9XzM_ADWQfUcBntk<8#+>N35Wg(8vYm1`ulEv6?x zGMhF93zZx&Vgy}|xuiac5kZXUK9z`mVj+VB!wKXe&a*fR;?Y0EjyA(}B-a$O^rwbc|{l>c#VwMAl>QROfU#Vk23+Tnu7^W-XN6YgO^QP?y4$u;;gE6tiotV!tqF)%FWi!zJGLu{$QPe5e9$j z4TaeJk;Js}rP}2Fvzj&wA#C{4=gtAD_`QmRyk5e{U>G?JP&6zg1VU56XDn!nfegKV z`yrnLGsU!oxR;;Z@i>Fo-n(nqRB>KDJS{4y4*HPZ@oci_v@Nt$^+fk<|1;PBpi80J z&ClnD;OJ9IRaK%$va^!I6jcX;jbN&j0R;wX6sPGwwfe}!^&jQ!*DH1~yHBsT=b?(j zUu#e4%Qh84Y~kybNblK01S~c(C z4cG;r2|E|0Ej)MCVSRCch-OEy-03xnH2ur#Hxt|B`X+%U&+ls85XW3VOgPBSF4Od7u@&zY2E9#d3M_l`(VTMLav;cE3=| zC%#%IwbqjYS{9c&AKJxmr$R&+MaGj-?}7N@{GL^I?b8=UQBLjGq-R_{cM%bJBw~~wpo&*47&;%ZV(OAO>CVggcX9GG z_gJ~qhQsEg za<=8e9=lzKt0)ryvDClX&E2|%AfpBm>ibHYS*y&C>w$NS?tLk|gcK2}GS@dg9Xh*( zbqA6yFS7LKUWTz;>|t>0m!)4aoWr!?Ixl*9Dc0xfm#;UAFtoN*<;qyrR=DF|1+_co28dSC<5lc&i6~sSbN6q<@-xyBv zmFUnn-Ub2*BgR?5lBo|g$c9>|EW(Zs~&s!{XZ?hrsa_8dI9-|Z3hC(ZF|;_`5~6!_oM(CPX2+7#9DWP*4LC* zaXbUbByVOr<^FMb42@oqz}gtgwIhY(Tg?3HB7rv49!C6tyO$BlJ`V5w9_c9FrA~wR z9*9p82i~_26y9XB8vp+2zngt5fs8;iCJjrb08gPQDkBX!;=z?h+z<=zLB! zXxsZFw8O2;0GcjQApgr{bd*15OOM7<@(&rv3 zH$E1{cU2RZ3jhs8u~w~8B}C-KKB$fOI&|yoHiJa*5ugqQc8Uf=&cAF3tXDjBHpT7D zbv5<-$A`=`5**pgq(yc8RW*2=yvQ2dQJ!z;%~`3wWS0S3g9J9^ZDFjDm)5meFdR5; z8S@kn_KZ&hGiEiPi1Fe!AF!S6^AjIx&p}C|IB)oGfy|K?Ss|0tiE+gP$pq$Cx9gZ? zMa(_*fC6s3Y~L6y_rE`S3>)M9c4M%S52xDm@=?MQ!sDmjBLo0YBV`KgfeP5~e>kLh zLX-CbQaj{^ZH15yo(W~++M<74Kx3NfQH(wn+s~SUVBJYe4o+YclaFu zk#YC~EHfmSU%c+=8o(<{cj=>4ZwwZ+$^LgKddhJCF`-VYS{f!Yqw;`$Tt0P=juc-b z?W;=Rk}A~yneyzNP}i#Wnzpw6fdIMeLjyBVSM2H7+{(C{+= z1$w6$WryE{SM!IhAN8SHY;;Yx{kQ2@ zwZRB|jC>mA2DB?63P3LQH2}9v1KdMh6;n3(g(1M)g{PZ5CR%^MzMxUuUk2p$()CGW zB4JoA3la$X5+UZnyQAA_UFY~1!;VaQ4qK$d?UTks*)zLna~$ALK;haGJMP5LVM5$X zU?P?92`sMla7qwIc2@Z7M8Q=hSROxnbXnnUH&PivC2JapUs2Wlp~RC`H+2|h2%Ca2 z!m>yMA8bD5-n1EcnXC-bv9Q}&^s>b`VLC@wGDe3v-*a$g^*qdqP~%FqpB;%nNd%Js zHGb8F_=?EG_*tn1Yz6iS6G^dGE(`&lelvhzS1|xlktKQGQvP5>K@Qq~_OkMu6~e#J z*A~R^9Lu7HFY3N@_6J$^uZiGLCOE|x=<2m8>D?CT8l$o$lub;cm%vI;;sR&sq1pNC zh2_3;JoXd^s+qS0)KrW4b^aQ44`-xPHB$#E4Kao?K33T@+Kff@4aftXI%AdF z8I=~bkGl7>4kpwJt_*c0s0Gh#b_t(+a-L&HnEp#py@of+4B3(h>@W&m=oa%$5-`+WwR;mK?f z*gNJ;eSjZS)YjwKVGmaohxs9%eUY?6vNLhf?O>AA7cA8As~9xh@MX)WdNCI;Lto~r zBW4JrjTNtN?<6j9!bGB!Od1U=0W}+m2cb3GSJ(~yX7Fq?aggJ=MoWCU1*}9D%&D;F zt61&2^LGI1D!%0;To%NuV)H7Kz?O2S3G8E)H)TT}l(g_V@S5U-`MQEe@#QW+%Z0;K zkV8Okbvo$gt;cqLUgymGef$qpBRN(<^R)4OZ#sHZ!77r)Z5oOmQsE8kufX5RujZN=(7%MPyguAqjF z2HtE)TXiM1k$5Byp2e{+`aR-~2O}(=%!10zlLlW+oLi*sBxojT`(OUNxaJgX>p2kuIhEfw2y3RI z@aD=aiZcueGX>niQL~gE{HW~x00)`0FV6eMwMEoN24TW55xF46L)ULuJ|D${e!8Ju zy;L=Vwog*qAyFb=D;ek6{agGhsB|~l&C8OfRm9b5^zdICHmb68@IHUt=$jO9H!XAd z2JW(qU|f==L74I`O%>O&gA!oauM8{;W8v|&Vj|zV<04_#oMzRSbG{)Zs)oiRrlg5D zc@Xq_yk9N6G|C3F()4()iv;SK>40u?Nv}~|=yncP22PxZ2<0V)aVKOh$3#*|;BwJ# zX3mi@6@i)HjVW{m!VoTB8ppFuhB4IV2vtBh)Ar6M4(fXJP!|W%0!q!>K+ScFjHS-M z`X`ZjX;3)0?}B0m+5uqb`TfTv2?kjE0U*SeM!;+HK`Cpj|EdB6=BMAAPB;a^IS!{8 z$niK|NIb9Ky`*q2w<*i%B0>9!=bmK~;6`aH@c}nB9NnDWAp2Hz@sFMA

WOib>wSnOT1RNm)yPl_KGk5TepgcuFN$MgMxbx#C9+|y3# zC|dR-*QasuLX# zH@)APX_P6q-W%v1OZ|_F8V6tf0S@8)#xa6(`*PcPIIFqZ9K!yV;#rh~WnVO!Y)T$Nq zh-&3{XE2^TpLi?pO@)yUWIWqNSaQ{vPWw_IR2q6~;>)*pQbd+Ky8%aj(k{TfDD>Ud zAd_>UZlN*ote(h*4xBOOJp7%*@#UK*wTHrh8j@$!wPW|qp{!aaP`%Iz2Rj9+$i0Vw zPJv8|osD>TbC}wN-h{S*zci(m%RnZsqWpb%tc2F)??;2#Sx!B2Udz@HVADH@F-OsS zXtEdi)Bh0P9H0Lcjwf}av1=k`ag11Ug{IdV-}jm-Z>G^k19CIrynRVih$NE>rBsqd z3~%w0qn!12EdO0bx~6vZ4nW&y{#K;OY|iIB69h7f$uoC?G8e}FZCNzpOEHMEKg22} z#3T@^hu4%YJHHlwqY}v?laODc`Q8kb56?XjO>c2QJd3F2^HY=~lfQN051nEF>_iGN zj-^g=OS%V-Y(yt;CUPXeRzY4g5Q=s9D|35P1abtYn{`UP3sVuXgfv@M@q z#<`L5tKi9j;N5rAg8VzFr=+F*jO%!>?#DYE%^03^t$$2<#vDfL`y?U!VZ4=mc{-GdwHizcAzNV+?+$b`5TRuQ~X??U&zeiFC zyWj3x8~z^(dgC4ay-nyAzbY}P9>0`;-{|5W#o!rP`DWAq2BFSTrwQ!A?O3N;KfO)@P|k2v7J72gYK=N7fZ~tw&gHzNX=zhFc}+yeW;ud7>omy^r4`wF5>tQ2g4$A8F2>g09#LenolJl6Ld&oU5% zKgZ}(Utq@zmB4U?jF>A_Xp9S{IWb(Jm9` zk#@B?iJeHuPANE7i3nnWGy_l_$3GNAnAQKQ?I?wdv{o*lv95TPJYZi zq1KMQd*Q5bee5lj8pZsQRS$JY)?v6nxzCdaDz7Wta2y!1#-$f&HWJ5l_dnOjC~0 zenS7Jl-o{gc1(Lj`EmWcR7|_dK#y%_WE=bt_TkFBK+^^(!2B2{E`v!Pt6R^S!Adu5KGJy*4||}_SbE!{CT6E) zopHp_v?T#(WlA6V-TL%a+^*`(^$$S$0!e4~);pW(^omI(u=P(~{0?|SC@%1$X`;Q3 zgb|#wyH{{@){kR^(&*uLp9O!!A66@;z-rBeRX}r+gIvF$_y4fMDQ!Yi^R|_8 zYNdYBnk%ekUC2Q*wsb5=!Iz`lv!bNTaW3(Sl&Ko63sX&<8R9{`)hI?uvZW1DtPW%P zX!Miqi;L1G#g_hJ+K$N zr=TD}W9}UUBMF8q`4NsEx%2&mV$ifa%@@%Xj1>QZPG0D%ezr;frUC+YO9(3lWYUO9 z2%Pc!G+znph8{~rdW|QoTw(y3uEm5rL*ogU@L#v1J;JH@fmTdOQ=oJV`MwXIME7xe zdaH`!-y8_mQ6(JN0cPa6N9}eTf0r1WrV^X(#UK4!_E3_eFd!&eXP}e5nS2QTX-CDD zO(yM&_keyO%|vLv)wZ&Fe_Hb7_7ak6BDC|y&FDla;DX9q^k@!#hIFq@%{Uy|+k}@Rsg|>&cxySrS6HDT z3;{AJBO3m(BW$cBiH%;kd}eV$y1dda83CmM??&nlNewk@=eSs>#l9Awhk7@qz2p_6 z@Fmb(+K^Zpv-nQTH-*_^bRWR3)>-I$N1#tmoha9Y+jtLu0^X=05bEXWE4u%j_C$d4 z{HY(#IFzv`&GHQq_6FDOork%~@Zr;OTqROLX1=rLZ4)b}UDnZ#_z0}rNio8QlZ799 zF*Nx!*GE$d%Lwlp%EMD2CKKsA`CPS*gRYDtGOV~6lu0(2Qb$wTH6j`RT3k6WW#xsU6PQ|!(;|ALLer6;Rk(v3!^bOI`q;=dD;{%d*sDdT(EC?h zdp}9vC)$Mn^$w)JY?<}g!^Gj0XeKFFr*6T?2&>G*_`#h8kZIV5g8WJK%*zJm)$crS3%TWdxh;yOYwSE+ikadg>%TO7 z>jMAeBHA&EL)ahzZS3wb@QsWBAct`l0hQ~P&bD|9ImP9^kDKz0D&OZH?`ETr4IxFc z8H#xU{DH{&CSa!a`e45<3an(X8gNm%Y_-EX#p4B#G(^Uk8bHu({j|IEECzTkZ>$$d zxkyn62t{(x_Mwd@yIKA(Y1Xb-{SBRqsJUpA_?c{CJ{$HK9yj-Gmi>cE6j#GB89-_p zo%Hcl4W;Fi+$?$|>_?-?pLyBzNYBQ(Bk*&1iA1Kzh1fDf#DDKtQTwQ_L}wKUk>69k z#xD^RN?rF)bmxVbXKQTVn&+3rQ3FP5i|`nY3Y0Kz?6PgY0jk2;06PYtm8&gAiNw=% z;a>y7Oz)gHTwg4{h*WqZfo7DaR}F>Fpe9=FSjWGm*UUmwvC(||n<<;W{-{xZ6>KU5 zQ=gnk0Wufk22)thIC)r~tjt!7*pr&XU1gRVHMF4l4X%4q+-l8nyF#dpSS!Fekz|}# zemFJCq$3ROpd54a-8J#2!QUb3{575HPUjq?m(nMev9inRe6q3j^&=ULb>3oB!rSw7 z{>iFpwz_&iSL~L2G=NgLm_tXOR!kzC8eVMGus!=-xaMO&4ZH)+t?#v(4&4%t&!s|Q zBN>cvXg7UPTWL>6-po5D0Gcs^0I>!W^%T{F5}K1^e!3Vxqf<*TM^>1xvu$( z3f_Q*So@hJSl^oxLs6TXB(AW}+7(JST`nZ^&-e4rlJabEn{pT;D}}30SWc%mGr)n- zUM_y5%}mDM9%V-sZW8U(8K(3~w5E2*2d>C5WXLC-`k;K^H$?Overuaw;1K97@W~Yi zKfzym?X{sTFOL7Z|6Z8|x_G_yE7ndQvL%YFuT`V?i!MXiEV=!h*)1ErWQ{>SI!`Vt zfv|-DcWVCN_~-SK1z|IT0IrfeDQf5Rq@{iJS!OJYT=pbW`T0~Vo=_*3Kjn^~b)`Wc zgvjlnCBjVWUZqe2Ni?H$jd^xW*mg+==shQ%d1D@6!ep0uj}`q%kTLIyJvRo?0TBB> zTJbx85EU8ueF4?zc;_n1#Fw?vdVNbfPeWwtV#dWTRTZ@zrM-@VxU7?&eXH3PGH0?K zltgPEIYCOp_Jc;x5&+>}AT)`f;Aeh%IReqkK@bqzAlV#OI?wV?+gXZP)$v9Ozb!a!z92h`3?XAw#=QS{!=yq z4}%iJ28gI6ve!?9T^jIx@N@PEDj1vrd1eJ^9vRZ2dfz54m zYsaz@k|f?mejovyN$hF!ss%B^jLdIz4&>llrwZ~}HKISMkTnCrE+8W)T`C}%!tg-J zWzVw4u-es}G%@rcIOu8*Q}~tbhx6Es?lpibvc5^a(A{5A?Gb;RZ`it9| zh9QkPfG(=g@$n5*8GLP}GDUMDZ2>g}rVZixk24*dev%3WC|hT}ENBGVpt zY_9+_k8UXQ7!rrq5PR~mBu&o)9f%&4KjhQp%&tZQTc<&S>#qfxRv-Utb}w0&{;=N_ z2!TH2k6f747~-w>){!sN3g7#w)?*0pFMm6%!;#4-Qw*Ryd+I#Wz2#4VcSNVBz()5rclvuEZ z*8DUnqrI{GNXGQ#SlQ_|bp0>V1v>uJIXg1)oy!ay8J22dpk!h;RpB203!Vd4Njxlq zok!@cf`31x*pq%~`{%j<=~07F1JEk4{-0g}sjJ|(S6)~Z)OtDmztb;} zv`7FHqTgfonMGE;Keie)PkdK`dfmsgCko=h-oSOdA#NV5#C`^3oBI&t zsKn^}e4-t#2CJ-xk4t{sAIo|8Mz1ewgVOX>D1z8A9&Yu z-`Sm6xCe>}dk^s!7f7-X8iJ9re@~z9DT(L;8Mt^p89qvr;=7RyD>FAiN^B-Nq-~=; zmEJ#qP0B?^yv{jQsf{v9YFyDbB`CJRunmYIO@X2i&OGR9F*6&XM9K-TeH~eW+#V3- z!B?O}zlu7x!}2MS#Hg05w9TkO9&P0H_PK_O=$EO4e;$(D*txx+=L4Lj2UpeQI`)8| z09SwHlWfw0K=O7d0_p{t^Tvqpv;U+J&`f^8vON1@79apN%PDYT>Nb>3OJ9t`Sd10#zgDYO_VY(? zmI9(B8l$jJop00ZHp=T`67}67t<38ZF3NgklYb(o77bZp0JsP;PJxXzpuHrwa+9}{ z%m|$JRg>*mcL`s8_+>M{juOQ_b3T-36vLhWj`@Mqu8-uSkpj4Qa|q$vqxG{p8t<ZE2Eyg<{N2ZzI>39k16-`+!%^=2*?L^u@(RjnEeQJDE$J_YMd5JY4AA1M1=Q0@1tjUB^ z9zM`9SuNgvvdW@XmnhFnr}sqIwEugtfOX;k+Z8XfzC z+AaptwrrD5YNO}-ST@NX>PWL8`GGwRAWQxE8FCEN(U~XW{>w3Xjjd! z?wH z;B!P+&AFDI^|X5Ef^k2oIk8*~4U>kOXiDdloeNMu!VO3e8*X5U#LqN7onetiV~nLS z6YQlfWEr)}E%+_Xp%-j}y3gRZYo@xugUiB$GS!WN(h;&au7GqiMU7><`9j1BzW$;e zR%sELnAp->*LpZy3Tl#v8NA{s!h^;L@P!dM0>AlP9)cJJ4WL5}j_lOmiK?v&(Ob*XDol}??r$<9k#?|%y>U$w6QB}$%qoRb+VQG5RnQyU$@OQ9V=|) zhhy{4sTGxYZI!}__^*o$vXwPgDom$jl@biEJ74%_Dw{lPK>%Pd4@jYF8?y3=N^~uLN+$+IN0u%aNY=T_UdNrxaWJz!~KomTN)@ogN1e2**u6ZWRS`_-+g^sVQ%P z-~CF3AFiX3ndx_R(M*e&>8FIM>;RAsfNi|2wgp0;)UOlE-G;y82PHlyHLnfi=rk(w z1e%%4q2`ds&wjtIHm&|}tqQU5s!oPAES~p)BPE6qgR`QSiZk1@W8R-nR!Cl^44 zA_8BBHiKapx>0^=JH04Hta&7J1AkB69r9f0#nB zYSU@%V5tYcIy_HuJjZ>z=cz~YnFTlFgLzOOB(593z8!B1lywNIoFiqbLrcJj!D^I7 zcYGm%m9c@thy!dfb7bFioo;YZ(?rHYR_ax}$_YL-d+5BDKAGc=)6Ft4;sZJVSvBDm zvYn-6o(h5ep}}xglgB6iBEo<<%?&5T2MH#8ee*47&Qs!Bs71|Xw|*`Li*z9a*T$}m zrX~kjbr_k9=%4#I1yKo{3&nSigcSKcQuD8mszt5kj8n5-E~H&tkhb-@Qc3&J{}90e zN?j>0w{DU-YxoKSSWHzJIMSI74Z@7Rjh9ubO{?P>oKt|uO6-w_*zcPN~kuR6l<6JMa zs(~6|O;|_`jPbnV?)TVN3aXTUC2!b(dnS?h>CgE!ji(VnH)Q0Z^LlGOrwGVz&*7Q6 z=v9ci{SJWN;hB~&g7uqDF2r5w8=d|9lQ*+*#6U=v#6SKMoH{;MpAns+^jQBnpe%VdPE}yIF@XOP3^f6JtUEq{rVg5@n)sa)8 zOzu0glB=J`EZq|~w%uZ1&fk$F&i|gku854sHYJW7`^17#W)njT<2y))Hv zkGc61$E*o^xYD1|(0ZPf@2XeeG|xsh%YiO}8@67t4Xis}8>3{O0v3yMApL=+8+(mN zJXD4bQpggcd6#T9Y-NrY%oI_MuZqHD>0}42rRxBe^sIans|K6G#+2I zLM6Z{e&;LDd}J{V#=XJaMteH`?WYPi0MS-Tusjv~S{;sKfwxs*??A5*$T|1G)Gv$4 zw!RQ_E&0Ioa=#COhl128-fqI56fsePf^nU-lc3%JPW*x()WVDLK-5*Qm?kmuVih($ zdJB&mlFYjDjhG~(C)zt`FCbBzc)r7vPb_t(daP`kh9*Ot;~@Zh9kzL;qY@}7{QYA=bz-A3)|ueLvydWa7Ft;Tm7@y9&K0yRd_X#2i`CUdV>2o9mY5W6K=VN-^?q z+*)+z9{8!3U&P>Z4>_OSRHuwq?(vxP${PABaeqdMQ=#3f^xtnSvG_PY>xlO~_)-M( z3y9B~-thB?FXCb3!E1jMLONqBKTqI|kx-U<)#^8ZqYqhO(=VU-!vNp6sN=YsMTwhi zWz1mA1;V4I=Oo7u$jdb(Mp#rsjn~#MpYu1!pFX%<*R#(WYx#Kd0`OaKi*VjF)YQ`R=GSy<3 ztt~_V-trH{M5Yh-GR<>ju?-XL&=D1<8oB6f@C_+Ig0#@*pFQeA_@ew#gszH{BQ#Ji zlj!wjH2=Fw&XM>LI99--_BB^?cHP)ZYc})z5z{K!-;FTPnPCZ!&NyZlHXxbk(BaO0 zT!U&t560Rv^OI-Qk$$zmpRxK>Heb;{DyJ;}x=s2J{ebHLr=nN!cPX4fA1BS9L0*jW z{?Sjck7PAM#`U9&IWe|3v&>8#Q6m@2Unj28q?6g15UZ%HRooB_lG0cGsw{u~cVeam z5)TcabTF0>mm=4N8JP74&H=}J`ev^1Z6%MFqBy31E-Q~wvKW+=NXGQSkX)KB!u>c3 z%6&~|pb)}228`;$56Z0^)`j7t+cRxY7oW>&d7oZj^M#H7?A5T0wP0YH`uGZgJtWU#fXIr@aE&UrG^8!mfGNP&}wd zY{BK<14VAH(lwa8%AfXWJ@LJy46As%Cu#l-Vl29?&jE;fQp@Y#>$9Gv1o}>&emZ30 z$Ur(L9qW`$K3V!WtX6G7KbGu^s41IpT+U(Cy+4oKIezypV0aTNMDbl0FB~y)H7GZr zY%;#$QhFg7pPidUKuo76g+BY$+G|$Xq>zrK=6An~;_14>FGRIbtbwvgt3p#_U0hZ) zw_heXvJq7MY@ceZBg~$}ZMfqr~9H<#! ziVAkznESwOYKv9jdO}vTgz%%wws^L?It16R*EjQ=>3w_f1+wys7rl=3K9~bCUUV}I z_Y*q1J<+c~)+?wg1)ligmAjF+)9TXKXFUx)1+7k7aPQ5k8gGWaisB_Cn|4~So9ZP| z3@?^d%8`!TpP$4jKRf{g2nuSm)QeI5pwLU0Wox#@K1+h-KBsK4|CgPuB8UD--K0Pj z+}KwRJ{_c&ex~M2G!ZxBA01?v^>2VOT#vkq4`~*IqN-(|34kUqGJe^?r{LI$hu!c? z{kF-^6woD{0a>&&WvnuVp_=R^Uz}pjNB{Im#2&07@?q!x3v9XPBZ+J*2|s zd5iP&XNe+IA`Y+_9&=gV{>QuPVL<=m0=~W9*g~ZvqKn7C^h&@HBPNL4t z?5_bLejU4xrAAsm_7~hlMGBt-(dRGE9Uc^}znMY(`FETa{_CiO#l^locsR#+9^O1rY!&r2 z3(VVm$)l!!JNRQnAtu>;|-3OqT#=YCUF6YG%kG$P<41r>XB;JfutzLEC zFN}+v=F);qb6rh7>%oW{b{*Y%d^iw~mOz&>J9rEY&(faF;!q@$CypMAz`zGasAuE3 zM^~&cNnMcq>~tg?nWNtk4^BU1gm$G&p?sDdz1{H3TIs;C$2}3bce?qLr zvBRsjb>H3i#%CfF!oR&;*&IilzXffO_Xs}EN_{P91OTVM9P0)YyMRXb)5P#1ps z{|Z}@&X-l^YN+GRM{nfFMGeb8Rc^_xj7V16+&c~L?JbFu5(gy&B*p7RmrZ&%-i5N} z7l$}Kv1zDN{T!ie7}#NVs&OiqW)s*KK*g!#t!O@H2HUwR11Oek*=JRiuE?F(&qn3+ zqKw%yhvgZ&OZ~OIPm-6qQZp4dnG>lBGI?SXz+`?r4yrKy zeq4lz9IvkakKGzFzW5r%mdfY>D3uFCt!sFDJ*?_bR~BZ}ooF&WNgLG|>K3PRox>8Y zio=X2Jib)E`|jys`3zz)fBT(XFubuh$)eQ@S#fwb8Nw8T=6w$$CiLBYPjpMC>&k0# zDFxny1e`OPy)oHvyAc#;V@prYnK#7Oc7#&m+GS(>ErUh2elRv}f3nBZGn*C@O}VDY z>8|yR%ESlDf@Bu*xRc!2*qX#Mo#VrYZ3w`G!PhC{o~3;6T%wqj<6(1uQ6!QxJlv8m zp1yy5nng7@>|(<>D>Nu|e3NHpzX{s!W0q`}=Xb%Y%bd_vIfhD_#7kqgr>xvOD^5A0 zDr}-(@>Ho#8NAi>wjzDp)vt}XVLE__@ldcsR+^^yN6M62EE?8wPdWBRI+GEL*xtm& zUDE-`X(=i24gSQz4LS>e&2HCU!n%GwtQ?`8ns$ycgwK?$jn5*ZIR_4;)hgauw^WJp zWWRn4qum^M;g&*{P@5GE-6A=9RQy-oMG>IR#pDc++8JU#Tb>W;)IzV2UdBxZj6EqyF&rKJM7Up^Vvj(3?W1_>l2@9M6M?q z5gF6DMe(G>%vx{hE=)(6Y2H7HON%oh9q{beE?a!BI}1$HpHcMnV;lT{QQ>Jb-1s}8|Cd_b|mM1 z!rq?FUJo_1`eArH;v_y7=tPWm>K82>xfS6qyb*ErJkPS7WdD;J-_zVWDT;mu5fnqkd}R`44wdF!SeCke;qF$3)U#ksaG8#~Q1~Q95|Djavl9Y8Y zNtJBEI^zc^Nun{sikqX{ude{jmZTKYn3;O;t8n2~ccX)WN%bm_)vmo=bEVhlQC+d8 zJ0-`bWBRWS{N=?d0(4qGI?yKgTYh@1nPLYM8WZP4DKa;p@AtR+1wEoxS(TWstu9oQ zXErh|*N`@=|EK#LaQP9Sj#=d+#*HTJXDLL%CW`K1Bk0 z@E`9aQl`-!c!$dE@l4njtBgzy_kcDJv)(8d4&wmVme#YS{*&QvLgoA{Z`Bx}pbS%v z1PWmYNhz~N`mNlz^0o(1U_!Wo%X3Lm;EQMO zIKf*o{)VrH+VYKk#<`-=M!y}jzV}2a(y1Lv%zXOnq)XiKI6J$1#j0!~^qBrk7Bs&D zyC=x#DqK6AkmT3023_xf@ApLyY zTOI^rbah9H5;3wV*|vPCO{!qTBBqpO?`u>D-~BFvoYWKTI5hdtboQL0I-B$G@bv^V zRVBzn3gbr52JBn=42F43l@hr4D#Mh%YuF)WvYY$qpP5R%G9VR^6rbO=Lu{GWx?tP~ zBwm2}R>A^aI=^adyT3WX6{-*oRZa?9})0l~f(FtzA5Ov+R8B1ekz2uN)%@wQs ze)tynIv>qharI^hDl4%A$fFv9-BP0;6%T*YPbvZLu5SvoYtH6yAT3`dy!_E^!gDSA z_V>S)peYIVrL4P<=)3YNfqo-}uu2NM`eoR~Rlu#g<`OsP_PH3}x+cr;lO8p9v8H7FBVdNrqU z5(&I|SsDYz-RL@MEf0JQ_jU+v!K&Cirq#8>!^p?JLvO}A-jUdFke_M8to!_Z(64pz z9I8`_&3<$g~T8(b91$!Ftzo*d<$ZpC@*%Ty`_ zWWQKDV@j{YB<$`rQQq_E9TX&P9@raBjF$WtH?5w`Skro>&F} z?6<0F{kNI?$xeh?(j@H87nc@taYRh8Wi>jHIde(JRgn{wSikB@(;^x>v^>OR%;t`i zaY(~ZgAc~FwQH`Xdf?82Wk#tC75W>2hOww@gN&vs-T>n^CNUw|dpgZ-Z~jw{a%GhfYKYdB93XIqOQ z?6h_A=B+E)r~~FbZnG%y^u3fTM2quDG05k$-};+U0<`@+BwTw$DuP{Z_7l5B49H@5 zzFv-BS{>XksVESg2|By}AkUpz_+;z215ccUcL{TGh)(acmhU#JmEXGI!I%^|(;m2F zq+cjQOKynRB*`Ys8&w_sv;Y+%GAIGVSr>Lreo4RyhzgBZlK%Qp@$?&as}7}c>~>qr zp((J6^$_ndX`*&5ZLykz#<*NgdrYa0PlCl_tIAzwGt)+Zr$|Sh`=MB|zv9Enr+WiZ zx4bel#=ZHq&Ks-cD%q9!5twVw zUdCD+Ohj}|o0#%fJ6)Lk?plv$&WPZ=Yfn|nq%5r%ICsBzx};^2)3^W^R;i4wl9<## zJlq!(UAbW_PWJ9J`Dl=S6ywSiPtQYKAdQ(<9b!K@jEJ243MZ?n5{F;*!2P#p?*SysISQ zHoghlDsM3nqGa+*mxSZ{p5MORLF^@#zIggFH4M=PAI{{>(GNDbs?H%BRFy91*+=9B z+Uc-f86aaJF93LXnLH3ksTt{?NH4Buk@s)$$t2sjYSF{iLwNC)!`9q7T_;P_D7=YtwzRFUl-n5b-MBmgb zQY3z$xpMp~3*6G;Y)M9vxFSYTcck=W2H;H(L3gn_^%Nge(>c0@B7(yHS!BS4uTY+( z>;kmNNfM(EjLg{&>X)D?4;;5bQWYO$Ni?yW<{#02GDpEzf;J@4*WXr3xd$JzE=s7B zi?{tiVPK$YcMJ)N66Jq_;R-nRz@6WKRvv8cWC(gb$n;F|v!>3f4;lpG_g^%?IR)G- z5jyBOi0Qc^@jsR+3v$dNp92_MfTT;hi@}4j0g^oxPpkc}6e!M)2lhlh=&nCBljMeS zM&YA`^R3u9AF^RB1ZX&tJJZ^LC=c*Z@&t4$5bp(pg7`S_8+M<%(9gYhP)lH(fGf$C z7o7>D_F6Haq7-<$9}C9mx7Fmr_z!m`-q4_RV$9>mUeYViJdDtfT%(oMu~%_`q7|?N zeDHuGxdP@9V>Q}@58a#h65pwV?)HNp{#Tw^RW0r!A2SVOj6ysuy5ylq`rhI?Y+u-1 zz2=|L3UJI^4MniY+>sr)4evK7`ICnNeJ#w{#i3t@ncV^b6433G{?K!DLH^Vq{2{%i z57nR*#3j*T^k?N=|9YMxL5JM%c{KT4%LKSH_iybz@Beu_c_lzf6{1;X7@E*bKk>hj zAVGilK1U7YxNZN>uRNJy!>bn?mRSGB1XA=+)tFrrfguZ`l^$^9&;+oSz@@oTUltRY zQcDAWnk?gj16FfWBL~W&Ka3Pc077!{P{qUJ_{MVp3>rqY{-kaEK2GBLB8X77|`*GV@Wi? z5%>HxNnW0yI(Bw*@&4v?7XG3>a^y66Me)3@K+#7 zF>kF#h&;B5JNUKpafO#*MdL332@k^HC!-jCywxG0o)4s=v!kjEv@%YyzAe9JbjnV_%II z0IOF!$qZ=1H;ca}N+AkYA03Q4+Uy`rw~y1&orbU3YmE#_&l^G*33<4ksWW;9fA_ElO1NVYH44m(ah< zP)=nm@hhke+=8D+Kc#KOJV%!5?BfBV%s-+IAd6{{ z+UHj?u(!8weKzgzv3J?}GV;~n zU#uk83fhk5C;;zXGM(Wom3JgDQLGx^pr@xdrSmjN{9jjq-K2Ht#djIEm3#uyo8<5Y z_`W{#Q;~x;vDKz)hDhH4r<>D+bFj-z)I`nGzwu%fEse{KpqJP8CPbxl2a}T@g@{x+ z#x4o#qIuEJn`6ZY=8l=ZyHmPZp+k}dCdbF!b)JzQ-4wT4A>tdjxw%c2U7M$zRDyzn z9+Tsn5E?Rk)Bk7RmoJ98F6@O~tK<&73Owk^qcVL*ydR+KyZqFpOp?#~X~*LiB{H~> zbr+KJnyfK27Z0(&r)U9KF{vk?MWf{*wnempQc{5dkVJiQaKzG{a8*moC7_%CjP_qA zKcjVS0(5gokqsYzU|#M3200#Q;^7H(Vl9B{ZNWWXcS@dt_jB32TK*;pF5bP?k1s?~s-b z7ZW$4*KE)>c0~;^lBJkF7eJ*B1@^4o9EG3w#Wz7NVj^E{`ZQ;_rV8n;>OS<(8-9FW zNZ8QWn3rO&B1GF^y^pxPy&Wrb8zJ7LO)vZ>*Wp74Wx8Vc2s{QqV!OIlQ%ICSbtftT zYgLb&ojGyEXv7W~D7Y>bqt*O+?2W+W&E>Irf;@!C)XIB*QF?K1?s`Ene<|m0Yh`7n z)A8n*tPB%>wCHUwWuki%;lwJi=dv0g#rCsA!@QS|BD=xKc5S4ghWM4RKRR{z9(mAcg(bL@r4q+^kB18Wyd7O%D+G{j2%;y z1z;aY4&~%jdyC#gwB3KfE@$8ZWVGLZYp^FKoE93z$D!4d`Qt=)eH1d^%)RSa&WQpvgNqGgq4n6dT0{3xk^?G8b|+%Dzrwgiw`MRmnG-T#g^_^y5fN*-n+Q$CnTK{6BAdXNs%!Dv)p^RCp>1l$|OK9 z{!28@!!*Qcr*=k-FRHt_5fLFD^Vuflx%1&kJaBO532_= zF}|=MUDweOq+90p>-`C9lT}z)SVD*?-XdP3$C9jxIh89ID5GZCRVg*1l4J3+1Dda)+r1o=w(g=KMxoD&Vt-FfRpDZ` z+bR8aT;kCNj5(V5h#kVZ<}{)f8j+O`+l5$m!d z8Jy*iA)W_UZHwM#X~=wMqq*JAqjknHfHa*Z~Hr&6x4Lx zXk6Jn;% zmS+76iS*?@_+q)(WcbY!Kt})vWRA28Cr8J<*}*6CdtRGTG6(OD&uSQ?w{oUQ-d|t8 zM6f1_avFSE&pbiOe@NEv;h83xe$?v_PL%=eU!VE(%(}d|vT_$`xm4fKa9U`&RA=}< zr@F9~#WV&?zce*FrKd-B6u31>{T`QLhVR@ZkI8RKv>#uM*+ID(uNu2(%W(J>ek*SF zhGU>7oPZawW)1zFF3hBM`Y`kEH`A3y=V6vhZQ~E#@WcB$*&L(Z2L2h_imj|H)k)SO zDYaKsRn0#=TrA_`Km*(~;3|8b<7=p@%x!j}7uMfSjYs$^hb7?n1Q{;!~5HDjjE zcKX`uUa1K1+<{By@pG7TuAAFVJqOSThsNVa&Bcw4Qy!;UbB#2#w6vouv)#a+enhJU z?0*weQ{FRa+(yx}#tq{SRse8Bm%$D)j)uy7?$0HxoJh9`7!o2CN`bxQKEqzc%xr1f zVX7iCZL6WJt<9Fv{ri(S3~1n77lhTRp7H?ezYcIv{dRYEC$!?|=;?3VM5Z6+X;F2xOF)?$&@~rebIC~^k?zX` z+(6)>_*Pftv8SPWyq_^A&=%qkv+|bMJVD?zI-ASY!@opDrW9UXT~Va1=RQ?qCXEFa zQ~s_rV2nZvY03V#rHr023Zy;+z(C;l<<`Mgs@`m<{vrjGW*v?@S!CEMk{LH*-hN-! zj`a+pX#-fs#q@ujjX&LeR25~qv_Vx>L_CU<#QVRHfBwafnb=j?LJ&#g>6 z{%djjp*!*WvmG#R5y;V{%DLMf>F`JZ0{`2~$0vVgem=)tIJLXGn`y&6-)nnncV~Nh zF3sN9!NEb<$LGAwqa}Uk!D|BsFO@Em_?3@=9NQ%W@NY9|MbU@TYy(!=xitQMplMdQBk>hr>m>0-eqpUCqR6)J!^jpR4@YK3=Z<9RY#_!%RX1noqD47iciH5%?3!K z!n^IM$|0?|n0`z3_0?d9>8jl(r)&D5T1_578Njb99?2FZ-dOFNcU$SfcGu^Ul^+p9yN!z(zlzz5Yr!pcuf}I x9p8^IrMBAH1hx5uSP>Y#zKz!amWuuzQ_reg;*7G1CmL`mzEYE~dTIXQ{{u4sU&sIe literal 0 HcmV?d00001 diff --git a/docs/source/figures/ixdat_flow.svg b/docs/source/figures/ixdat_flow.svg new file mode 100644 index 00000000..d50d67ce --- /dev/null +++ b/docs/source/figures/ixdat_flow.svg @@ -0,0 +1,1137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + relational database + + + + + data sharing & transparency + + + + + + } + } + ixdat + + + + A A + + B B + x act. /[s-1] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diss. /[s-1] + plotting tools + + + analysistools @1.6 VRHE + EC-MS SEC ICP-MS FE-EPR XRD XPSc LEIS SEMc + + + + + + + diff --git a/docs/source/figures/logo.svg b/docs/source/figures/logo.svg new file mode 100644 index 00000000..076e9b9f --- /dev/null +++ b/docs/source/figures/logo.svg @@ -0,0 +1,172 @@ + + + + + + + + + + image/svg+xml + + + + + + + ixdat + + + + + + + + + + + + + + + + + + diff --git a/docs/source/index.rst b/docs/source/index.rst index be0a3674..ba50b2e2 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,10 +1,47 @@ +.. figure:: figures/logo.svg + :width: 200 + + Documentation for ``ixdat`` ########################### The in-situ experimental data tool ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +With ``ixdat``, you can import, combine, and export complex experimental datasets +as simply as:: + + ms = Measurement.read("2021-03-30 16_59_35 MS data.tsv", reader="zilien") + ms.plot_measurement() + + ec = Measurement.read_set("awesome_EC_data", reader="biologic") + ec.plot_measurement() + + ecms = ec + ms + ecms.plot_measurement() + + ecms.export("my_combined_data.csv") + +Output: + +.. figure:: figures/ixdat_example_figures.png + :width: 700 + + In-situ experimental data made easy + +Or rather, than exporting, you can take advantage of ``ixdat``'s powerful analysis +tools and database backends to be a one-stop tool from messy raw data to public +repository accompanying your breakthrough publication and advancing our field. + + +The documentation +----------------- + Welcome to the ``ixdat`` documentation. We hope that you can find what you are looking for here! + +The Introduction has a list of the techniques and file types supported so far. + This documentation, like ``ixdat`` itself, is a work in progress and we appreciate any feedback or requests `here `_. @@ -13,9 +50,12 @@ Note, we are currently compiling from the branch, not the master branch. .. toctree:: - :maxdepth: 2 + :maxdepth: 1 introduction + tutorials + extended-concept + developing measurement technique_docs/index data-series diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst index 67b4dd34..8442cb4f 100644 --- a/docs/source/introduction.rst +++ b/docs/source/introduction.rst @@ -2,26 +2,53 @@ Introduction ============ -.. figure:: figures/ixdat_profile_pic.svg - :width: 200 +``ixdat`` provides a powerful **object-oriented** interface to experimental data, +especially in-situ experimental data for which it is of interest to combine data obtained +simultaneously from multiple techniques. +In addition to a **pluggable** ``reader`` interface for importing your data format, it +includes pluggable exporters and plotters, as well as a database interface. - The power of combining techniques (fig made with ``EC_Xray``, an ``ixdat`` precursor) - - -``ixdat`` will provide a powerful **object-oriented** interface to experimental data, especially in-situ experimental data for which it is of interest to combine data obtained simultaneously from multiple techniques. - -``ixdat`` will replace the existing electrochemistry - mass spectrometry data tool, `EC_MS `_, and will thus become a powerful stand-alone tool for analysis and visualization of data acquired by the equipment of `Spectro Inlets `_ and other EC-MS solutions. -It will also replace the existing electrochemistry - synchrotron GIXRD data tool, `EC_Xray `_ when needed. -Over time, it will acquire functionality for more and more techniques. - -In addition to a **pluggable** parser interface for importing your data format, it will include pluggable exporters and plotters, as well as a database interface. - -We will update this documentation as features are added. +For the philosophy behind ixdat, see :ref:`concept`. ``ixdat`` is free and open source software and we welcome input and new collaborators. -The source is here: https://github.com/ixdat/ixdat - -For a long motivation, see :ref:`concept`. +See :ref:`developing`. + +Supported techniques +-------------------- + +.. list-table:: Techniques and Readers + :widths: 20 15 50 + :header-rows: 1 + + * - Measurement technique + - Status + - Readers + * - Electrochemistry (EC) + - Released + - - biologic: .mpt files from Biologic's EC-Lab software + - autolab: ascii files from AutoLab's NOVA software + - ivium: .txt files from Ivium's IviumSoft software + * - Mass Spectrometry (MS) + - Released + - - pfeiffer: .dat files from Pfeiffer Vacuum's PVMassSpec software + - cinfdata: text export from DTU Physics' cinfdata system + - zilien: .tsv files from Spectro Inlets' Zilien software + * - Electrochemistry - Mass Spectrometry (EC-MS) + - Released + - - zilien: .tsv files from Spectro Inlets' Zilien software + - EC_MS: .pkl files from the legacy EC_MS python package + * - Spectroelectrochemistry (SEC) + - Development + - - msrh_sec: .csv file sets from Imperial College London's SEC system + * - X-ray photoelectron spectroscopy (XPS) + - Future + - + * - X-ray diffraction (XRD) + - Future + - + * - Low-Energy Ion Scattering (LEIS) + - Future + - Installation @@ -41,12 +68,11 @@ you may need to upgrade to the latest version. This is also easy. Just type:: $ pip install --upgrade ixdat -Tutorials ----------- -``ixdat`` has a growing number of tutorials available as jupyter notebooks. The tutorials -are here: https://github.com/ixdat/tutorials -.. toctree:: - :maxdepth: 2 - extended-concept \ No newline at end of file +ixdat workflow +-------------- +.. figure:: figures/ixdat_flow.png + :width: 500 + + The power of combining techniques \ No newline at end of file diff --git a/docs/source/tutorials.rst b/docs/source/tutorials.rst new file mode 100644 index 00000000..1c9a4e3d --- /dev/null +++ b/docs/source/tutorials.rst @@ -0,0 +1,6 @@ +========= +Tutorials +========= + +``ixdat`` has a growing number of tutorials available as jupyter notebooks. + diff --git a/logo.svg b/logo.svg new file mode 100644 index 00000000..076e9b9f --- /dev/null +++ b/logo.svg @@ -0,0 +1,172 @@ + + + + + + + + + + image/svg+xml + + + + + + + ixdat + + + + + + + + + + + + + + + + + + From 9ccb3e81effb9ac4f10f4b0e0d7b8b35844f8720 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Fri, 2 Jul 2021 07:43:26 +0100 Subject: [PATCH 062/118] more improvement in docs including tutorials --- .gitignore | 6 +-- docs/source/developing.rst | 12 ++++-- docs/source/index.rst | 8 ++-- docs/source/introduction.rst | 10 +++-- docs/source/technique_docs/index.rst | 1 + docs/source/technique_docs/sec.rst | 11 ++++++ docs/source/tutorials.rst | 57 +++++++++++++++++++++++++++- 7 files changed, 90 insertions(+), 15 deletions(-) create mode 100644 docs/source/technique_docs/sec.rst diff --git a/.gitignore b/.gitignore index c501bbcc..f4a89594 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Big files from tests -*development_scripts/*.png -*development_scripts/*.svg -*docs/source/figures/big/* +development_scripts/*.png +development_scripts/*.svg +docs/source/figures/big_src/* # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/docs/source/developing.rst b/docs/source/developing.rst index 873d95a4..694d57ea 100644 --- a/docs/source/developing.rst +++ b/docs/source/developing.rst @@ -1,3 +1,5 @@ +.. _developing: + ================ Developing ixdat ================ @@ -7,8 +9,8 @@ should support and doesn't, it might be because **you** haven't coded it yet. Here are a few resources to help you get started developing ixdat. -Github -****** +Git and Github +************** The source code for ixdat (and this documentation) lives at: https://github.com/ixdat/ixdat @@ -38,13 +40,17 @@ To develop ixdat, you will need to use git and github. This means pip install --upgrade ixdat +- Check out the branch you want to work from. Note, this is also how to *use* a feature that is under development.:: + + git checkout branch_to_work_from + - Make a branch using:: git branch my_branch_name git checkout my_branch_name -- Develop your feature, commiting regularly and pushing regularly to your github account. +- Develop your feature, committing regularly and pushing regularly to your github account. - When it's ready (i.e., works like you want, and passes linting and testing), make a pull request! A pull request (PR) is an awesome open review process that gives others a chance to comment and suggest diff --git a/docs/source/index.rst b/docs/source/index.rst index ba50b2e2..31bb82c7 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,12 +12,12 @@ The in-situ experimental data tool With ``ixdat``, you can import, combine, and export complex experimental datasets as simply as:: - ms = Measurement.read("2021-03-30 16_59_35 MS data.tsv", reader="zilien") - ms.plot_measurement() - ec = Measurement.read_set("awesome_EC_data", reader="biologic") ec.plot_measurement() + ms = Measurement.read("2021-03-30 16_59_35 MS data.tsv", reader="zilien") + ms.plot_measurement() + ecms = ec + ms ecms.plot_measurement() @@ -40,7 +40,7 @@ The documentation Welcome to the ``ixdat`` documentation. We hope that you can find what you are looking for here! -The Introduction has a list of the techniques and file types supported so far. +The :ref:`Introduction` has a list of the techniques and file types supported so far. This documentation, like ``ixdat`` itself, is a work in progress and we appreciate any feedback or requests `here `_. diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst index 8442cb4f..6180cc77 100644 --- a/docs/source/introduction.rst +++ b/docs/source/introduction.rst @@ -1,3 +1,5 @@ +.. _Introduction: + ============ Introduction ============ @@ -23,21 +25,21 @@ Supported techniques * - Measurement technique - Status - Readers - * - Electrochemistry (EC) + * - :ref:`electrochemistry` - Released - - biologic: .mpt files from Biologic's EC-Lab software - autolab: ascii files from AutoLab's NOVA software - ivium: .txt files from Ivium's IviumSoft software - * - Mass Spectrometry (MS) + * - :ref:`mass-spec` - Released - - pfeiffer: .dat files from Pfeiffer Vacuum's PVMassSpec software - cinfdata: text export from DTU Physics' cinfdata system - zilien: .tsv files from Spectro Inlets' Zilien software - * - Electrochemistry - Mass Spectrometry (EC-MS) + * - :ref:`ec-ms` - Released - - zilien: .tsv files from Spectro Inlets' Zilien software - EC_MS: .pkl files from the legacy EC_MS python package - * - Spectroelectrochemistry (SEC) + * - :ref:`sec` - Development - - msrh_sec: .csv file sets from Imperial College London's SEC system * - X-ray photoelectron spectroscopy (XPS) diff --git a/docs/source/technique_docs/index.rst b/docs/source/technique_docs/index.rst index bcfddf1c..751f3034 100644 --- a/docs/source/technique_docs/index.rst +++ b/docs/source/technique_docs/index.rst @@ -25,3 +25,4 @@ A full list of the techniques and there names is in the ``TECHNIQUE_CLASSES`` di electrochemistry mass_spec ec_ms + sec diff --git a/docs/source/technique_docs/sec.rst b/docs/source/technique_docs/sec.rst new file mode 100644 index 00000000..792a37d7 --- /dev/null +++ b/docs/source/technique_docs/sec.rst @@ -0,0 +1,11 @@ +.. _sec: + +Spectro-Electrochemistry +======================== + +Spectro-Electrochemsitry (SEC) is under development and not yet released. To use it, you +will need to run from the `**[spectroelectrochemistry]** `_ +branch of the repository. See :ref:`developing`. + +An example of using ixdat to plot SEC data is +`here `_. diff --git a/docs/source/tutorials.rst b/docs/source/tutorials.rst index 1c9a4e3d..a8c6ba9d 100644 --- a/docs/source/tutorials.rst +++ b/docs/source/tutorials.rst @@ -2,5 +2,60 @@ Tutorials ========= -``ixdat`` has a growing number of tutorials available as jupyter notebooks. +``ixdat`` has a growing number of tutorials and examples available. +Ipython notebook tutorials +-------------------------- +Jupyter notebooks are available in the ixdat Tutorials repository: +https://github.com/ixdat/tutorials/ + +This repository is a bit of a mess at the moment, apologies, but the tutorials themselves are +not bad, if we may say so ourselves. More are needed. Right now there are two, +both based on electrochemistry data: + +Loading, selecting, calibrating, and exporting data +*************************************************** + +Location: `loading_appending_and_saving/export_demo_data_as_csv.ipynb `_ + +This tutorial shows with electrochemistry data how to load, append, and export data. +It shows, among other things, the **appending + operator** and how to use the **backend** (save() and get()). + +It requires the data files `here `_. + + +Comparing cycles of a cyclic voltammagram +***************************************** + +Location: `simple_ec_analysis/difference_between_two_cvs.ipynb `_ + +This tutorial, together with the previous one, shows the ``ixdat``'s API for electrochemistry data. +It demonstrates, with CO stripping as an example, the following features: + +- Selecting cyclic voltammatry cycles + +- Integrating current to get charge passed + +- Lining seperate cycles up with respect to potential + +It reads ixdat-exported data directly from github. +A worked example based on the methods in this tutorial + + +Article repositories +-------------------- + +Calibrating EC-MS data +********************** +See these two examples, respectively, for making and using an ixdat EC-MS calibration: + +- https://github.com/ScottSoren/pyCOox_public/blob/main/paper_I_fig_S1/paper_I_fig_S1.py + +- https://github.com/ScottSoren/pyCOox_public/blob/main/paper_I_fig_2/paper_I_fig_2.py + + +Development scripts +------------------- +The basics of importing and plotting from each reader are demonstrated in +the **development_scripts/reader_testers** folder of the repository: +https://github.com/ixdat/ixdat/tree/user_ready/development_scripts/reader_testers \ No newline at end of file From 53ac8fc114c91191a0e7118d0b7a89a5289c12f9 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Fri, 2 Jul 2021 07:47:06 +0100 Subject: [PATCH 063/118] logo in docs, updated README, v0.1.5 --- README.rst | 99 ++++++++++++++++++++++++++++++++++++++++--- docs/source/conf.py | 1 + docs/source/index.rst | 2 +- src/ixdat/__init__.py | 2 +- 4 files changed, 96 insertions(+), 8 deletions(-) diff --git a/README.rst b/README.rst index 6f90a5a0..7b73230c 100644 --- a/README.rst +++ b/README.rst @@ -1,20 +1,100 @@ +.. figure:: docs/sourc/figures/logo.svg + :width: 500 + ============================================= ``ixdat``: The In-situ Experimental Data Tool ============================================= +With ``ixdat``, you can import, combine, and export complex experimental datasets +as simply as:: + + ec = Measurement.read_set("awesome_EC_data", reader="biologic") + ec.plot_measurement() + + ms = Measurement.read("2021-03-30 16_59_35 MS data.tsv", reader="zilien") + ms.plot_measurement() + + ecms = ec + ms + ecms.plot_measurement() + + ecms.export("my_combined_data.csv") + +Output: + +.. figure:: docs/source/figures/ixdat_example_figures.png + :width: 700 + + In-situ experimental data made easy + +Or rather, than exporting, you can take advantage of ``ixdat``'s powerful analysis +tools and database backends to be a one-stop tool from messy raw data to public +repository accompanying your breakthrough publication and advancing our field. + +About +----- + ``ixdat`` provides a powerful **object-oriented** interface to experimental data, especially in-situ experimental data for which it is of interest to combine data obtained simultaneously from multiple techniques. Documentation is at https://ixdat.readthedocs.io -``ixdat`` has replaced the existing electrochemistry - mass spectrometry data tool, `EC_MS `_, -and will thus become a powerful stand-alone tool for analysis and visualization of data acquired by the equipment of `Spectro Inlets `_ and other EC-MS solutions. -It will also replace the existing electrochemistry - synchrotron GIXRD data tool, `EC_Xray `_ when needed. -Over time, it will acquire functionality for more and more techniques. Please help get yours incorporated! - In addition to a **pluggable** parser interface for importing your data format, ``ixdat`` it also includes pluggable exporters and plotters, as well as a database interface. A relational model of experimental data is thought into every level. +.. list-table:: Techniques and Readers + :widths: 20 15 50 + :header-rows: 1 + + * - Measurement technique + - Status + - Readers + * - Electrochemistry (EC) + - Released + - - biologic: .mpt files from Biologic's EC-Lab software + - autolab: ascii files from AutoLab's NOVA software + - ivium: .txt files from Ivium's IviumSoft software + * - Mass Spectrometry (MS) + - Released + - - pfeiffer: .dat files from Pfeiffer Vacuum's PVMassSpec software + - cinfdata: text export from DTU Physics' cinfdata system + - zilien: .tsv files from Spectro Inlets' Zilien software + * - EC-MS + - Released + - - zilien: .tsv files from Spectro Inlets' Zilien software + - EC_MS: .pkl files from the legacy EC_MS python package + * - X-ray photoelectron spectroscopy (XPS) + - Future + - + * - X-ray diffraction (XRD) + - Future + - + * - Low-Energy Ion Scattering (LEIS) + - Future + - + +Tutorials are described at https://ixdat.readthedocs.io/en/latest/tutorials.html + +Installation +------------ + +To use ``ixdat``, you need to have python installed. We recommend +`Anaconda python `_. + +To install ``ixdat``, just type in your terminal or Anaconda prompt:: + + $ pip install ixdat + +And hit enter. + +``ixdat`` is under development, and to make use of the newest features, +you may need to upgrade to the latest version. This is also easy. Just type:: + + $ pip install --upgrade ixdat + + +Article repositories +-------------------- + ``ixdat`` is shown in practice in a growing number of open repositories of data and analysis for academic publications: @@ -25,7 +105,14 @@ for academic publications: - Dynamic Interfacial Reaction Rates from Electrochemistry - Mass Spectrometry - - Article: + - Article: https://doi.org/10.1021/acs.analchem.1c00110 - Repository: https://github.com/kkrempl/Dynamic-Interfacial-Reaction-Rates + +Join us +------- + `ixdat`` is free and open source software and we welcome input and new collaborators. Please help us improve! + +Contact us (sbscott@ic.ac.uk) or just +`get started developing `_. \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index 6d827791..2ceb12f8 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -64,6 +64,7 @@ # a list of builtin themes. # html_theme = "sphinx_rtd_theme" +html_logo = 'logo.svg' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/docs/source/index.rst b/docs/source/index.rst index 31bb82c7..af5dceee 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,6 +1,6 @@ .. figure:: figures/logo.svg - :width: 200 + :width: 500 Documentation for ``ixdat`` diff --git a/src/ixdat/__init__.py b/src/ixdat/__init__.py index 2b2e2239..cef1ea29 100644 --- a/src/ixdat/__init__.py +++ b/src/ixdat/__init__.py @@ -1,6 +1,6 @@ """initialize ixdat, giving top-level access to a few of the important structures """ -__version__ = "0.1.3" +__version__ = "0.1.5" __title__ = "ixdat" __description__ = "The in-situ experimental data tool" __url__ = "https://github.com/ixdat/ixdat" From 7cc94a8c31114361cbfebfc2d441b9cda31a98ea Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Fri, 2 Jul 2021 07:59:03 +0100 Subject: [PATCH 064/118] fix logo paths --- README.rst | 4 +- docs/source/conf.py | 2 +- logo.svg | 172 -------------------------------------------- 3 files changed, 3 insertions(+), 175 deletions(-) delete mode 100644 logo.svg diff --git a/README.rst b/README.rst index 7b73230c..cf32c57c 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ -.. figure:: docs/sourc/figures/logo.svg - :width: 500 +.. figure:: docs/source/figures/logo.svg + :width: 200 ============================================= ``ixdat``: The In-situ Experimental Data Tool diff --git a/docs/source/conf.py b/docs/source/conf.py index 2ceb12f8..65754179 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -64,7 +64,7 @@ # a list of builtin themes. # html_theme = "sphinx_rtd_theme" -html_logo = 'logo.svg' +html_logo = 'figures/logo.svg' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/logo.svg b/logo.svg deleted file mode 100644 index 076e9b9f..00000000 --- a/logo.svg +++ /dev/null @@ -1,172 +0,0 @@ - - - - - - - - - - image/svg+xml - - - - - - - ixdat - - - - - - - - - - - - - - - - - - From 10d32b245eacfdcfc5e683d2bc7938e7004dd99e Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Sat, 10 Jul 2021 16:39:05 +0100 Subject: [PATCH 065/118] SpectrumSeries --- .gitignore | 1 + .../reader_testers/test_msrh_sec_reader.py | 29 +++-- src/ixdat/measurements.py | 16 +++ src/ixdat/plotters/sec_plotter.py | 5 +- src/ixdat/plotters/spectrum_plotter.py | 98 ++++++++++++++++ src/ixdat/spectra.py | 106 +++++++++++++++--- .../techniques/spectroelectrochemistry.py | 40 +++++++ 7 files changed, 269 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index 40ad90ee..7debfd35 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Examples *.png *.csv +docs/source/figures/big_src # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/development_scripts/reader_testers/test_msrh_sec_reader.py b/development_scripts/reader_testers/test_msrh_sec_reader.py index 11b30cc9..d8798e70 100644 --- a/development_scripts/reader_testers/test_msrh_sec_reader.py +++ b/development_scripts/reader_testers/test_msrh_sec_reader.py @@ -1,22 +1,29 @@ +"""Demonstrate simple importing and plotting SEC data""" + from pathlib import Path from ixdat import Measurement data_dir = Path.home() / "Dropbox/ixdat_resources/test_data/sec" -path_to_sec = data_dir / "test-7SEC.csv" -path_to_wl = data_dir / "WL.csv" -path_to_jv = data_dir / "test-7_JV.csv" - sec_meas = Measurement.read( - path_to_sec, - path_to_wl_file=path_to_wl, - path_to_jv_file=path_to_jv, + data_dir / "test-7SEC.csv", + path_to_wl_file=data_dir / "WL.csv", + path_to_jv_file=data_dir / "test-7_JV.csv", scan_rate=1, tstamp=1, reader="msrh_sec", ) -axes = sec_meas.plot_measurement(V_ref=0.66, cmap_name="jet", make_colorbar=True) -axes[0].get_figure().savefig("sec_example_with_colorbar.png") -ax = sec_meas.plot_waterfall(V_ref=0.66, cmap_name="jet", make_colorbar=True) -ax.get_figure().savefig("sec_waterfall_example.png") +axes = sec_meas.plot_measurement( + V_ref=0.66, + cmap_name="jet", + make_colorbar=False, +) + +ax = sec_meas.plot_waterfall( + V_ref=0.66, + cmap_name="jet", + make_colorbar=True, +) + +sec_meas.get_dOD_spectrum(V=1.5, V_ref=1.2).plot(color="k") diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index ffb590bd..81c5be03 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -790,6 +790,22 @@ def __add__(self, other): ) return cls.from_dict(obj_as_dict) + def join(self, other, join_on=None): + """Join two measurements based on a shared data series + + This involves projecting all timeseries from other's data series so that the + variable named by `join_on` is shared between all data series. + This is analogous to an explicit inner join. + + Args: + other (Measurement): a second measurement to join to self + join_on (str or tuple): Either a string, if the value to join on is called + the same thing in both measurements, or a tuple of two strings if it is + not. + The variable described by join_on must be monotonically increasing in + both measurements. + """ + # ------- Now come a few module-level functions for series manipulation --------- # TODO: move to an `ixdat.build` module or similar. diff --git a/src/ixdat/plotters/sec_plotter.py b/src/ixdat/plotters/sec_plotter.py index 2cfb2d25..daa74f0e 100644 --- a/src/ixdat/plotters/sec_plotter.py +++ b/src/ixdat/plotters/sec_plotter.py @@ -6,7 +6,10 @@ class SECPlotter(MPLPlotter): - """An spectroelectrochemsitry (SEC) matplotlib plotter.""" + """An spectroelectrochemsitry (SEC) matplotlib plotter. + + FIXME: This should make use of the code in spectrum_plotter.SpectrumSeriesPlotter + """ def __init__(self, measurement=None): """Initiate the plotter with its default Meausurement to plot""" diff --git a/src/ixdat/plotters/spectrum_plotter.py b/src/ixdat/plotters/spectrum_plotter.py index 92c4b3e7..acee9044 100644 --- a/src/ixdat/plotters/spectrum_plotter.py +++ b/src/ixdat/plotters/spectrum_plotter.py @@ -1,3 +1,6 @@ +import numpy as np +import matplotlib as mpl +from matplotlib import pyplot as plt from .base_mpl_plotter import MPLPlotter @@ -13,3 +16,98 @@ def plot(self, spectrum=None, ax=None, **kwargs): ax.set_xlabel(spectrum.x_name) ax.set_ylabel(spectrum.y_name) return ax + + +class SpectrumSeriesPlotter(MPLPlotter): + def __init__(self, spectrum_series=None): + self.spectrum_series = spectrum_series + + @property + def plot(self): + return self.heat_plot + + def plot_average(self, spectrum_series=None, ax=None, **kwargs): + spectrum_series = spectrum_series or self.spectrum_series + if not ax: + ax = self.new_ax() + ax.plot(spectrum_series.x, spectrum_series.y_average, **kwargs) + ax.set_xlabel(spectrum_series.x_name) + ax.set_ylabel(spectrum_series.y_name + " (average)") + return ax + + def heat_plot( + self, + spectrum_series=None, + tspan=None, + xspan=None, + ax=None, + cmap_name="inferno", + make_colorbar=False, + ): + field = spectrum_series.field + tseries = field.axes_series[0] + t = tseries.data + xseries = field.axes_series[1] + x = xseries.data + data = field.data + + if tspan: + t_mask = np.logical_and(tspan[0] < t, t < tspan[-1]) + t = t[t_mask] + data = data[t_mask, :] + if xspan: + wl_mask = np.logical_and(xspan[0] < x, x < xspan[-1]) + x = x[wl_mask] + data = data[:, wl_mask] + + ax.imshow( + data.swapaxes(0, 1), + cmap=cmap_name, + aspect="auto", + extent=(t[0], t[-1], x[0], x[-1]), + ) + + ax.set_xlabel( + (spectrum_series.t_str if hasattr(spectrum_series, "t_str") else None) + or tseries.name + ) + ax.set_ylabel(xseries.name) + if make_colorbar: + cmap = mpl.cm.get_cmap(cmap_name) + norm = mpl.colors.Normalize(vmin=np.min(data), vmax=np.max(data)) + cb = plt.colorbar( + mpl.cm.ScalarMappable(norm=norm, cmap=cmap), + ax=ax, + use_gridspec=True, + anchor=(0.75, 0), + ) + cb.set_label("$\Delta$ opdical density") + return ax + + def plot_waterfall( + self, spectrum_series=None, cmap_name="jet", make_colorbar=True, ax=None + ): + spectrum_series = spectrum_series or self.spectrum_series + if not ax: + ax = self.new_ax() + field = spectrum_series.field + data = field.data + t = spectrum_series.t + wl = spectrum_series.wl + + cmap = mpl.cm.get_cmap(cmap_name) + norm = mpl.colors.Normalize(vmin=np.min(t), vmax=np.max(t)) + + for i, v_i in enumerate(t): + spec = data[i] + color = cmap(norm(v_i)) + ax.plot(wl, spec, color=color) + + ax.set_xlabel(spectrum_series.x_name) + ax.set_ylabel() + + if make_colorbar: + cb = plt.colorbar(mpl.cm.ScalarMappable(norm=norm, cmap=cmap), ax=ax) + cb.set_label(spectrum_series.potential.name) + + return ax diff --git a/src/ixdat/spectra.py b/src/ixdat/spectra.py index 897a13f7..23430dfc 100644 --- a/src/ixdat/spectra.py +++ b/src/ixdat/spectra.py @@ -1,6 +1,7 @@ import numpy as np from .db import Saveable, PlaceHolderObject from .data_series import DataSeries, TimeSeries, Field +from .exceptions import BuildError class Spectrum(Saveable): @@ -8,9 +9,9 @@ class Spectrum(Saveable): A spectrum is a data structure including one-dimensional arrays of x and y variables of equal length. Typically, information about the state of a sample can be obtained - from a plot of y (e.g. adsorbance OR intensity OR counts) vs x (e.g energy OR + from a plot of y (e.g. absorbance OR intensity OR counts) vs x (e.g energy OR wavelength OR angle OR mass-to-charge ratio). Even though in reality it takes time - to require a spectrum, a spectrom is considered to represent one instance in time. + to require a spectrum, a spectrum is considered to represent one instance in time. In ixdat, the data of a spectrum is organized into a Field, where the y-data is considered to span a space defined by the x-data and the timestamp. If the x-data @@ -27,6 +28,7 @@ class Spectrum(Saveable): "name", "technique", "metadata", + "tstamp", "sample_name", "field_id", } @@ -38,6 +40,7 @@ def __init__( metadata=None, sample_name=None, reader=None, + tstamp=None, field=None, field_id=None, ): @@ -49,6 +52,7 @@ def __init__( technique (str): The spectrum technique sample_name (str): The sample name reader (Reader): The reader, if read from file + tstamp (float): The unix epoch timestamp of the spectrum field (Field): The Field containing the data (x, y, and tstamp) field_id (id): The id in the data_series table of the Field with the data, if the field is not yet loaded from backend. @@ -57,6 +61,7 @@ def __init__( self.name = name self.technique = technique self.metadata = metadata + self.tstamp = tstamp self.sample_name = sample_name self.reader = reader self._field = field or PlaceHolderObject(field_id, cls=Field) @@ -117,7 +122,7 @@ def from_data( y_name="y", x_unit_name=None, y_unit_name=None, - **kwargs + **kwargs, ): """Initiate a spectrum from data. Does so via cls.from_series @@ -146,15 +151,13 @@ def from_series(cls, xseries, yseries, tstamp, **kwargs): tstamp (timestamp): the timestamp of the spectrum. Defaults to None. kwargs: key-word arguments are passed on ultimately to cls.__init__ """ - tseries = TimeSeries( - data=np.array([0]), tstamp=tstamp, unit_name="s", name="spectrum time / [s]" - ) field = Field( - data=np.array([yseries.data]), - axes_series=[xseries, tseries], + data=yseries.data, + axes_series=[xseries], name=yseries.name, unit_name=yseries.unit_name, ) + kwargs.update(tstamp=tstamp) return cls.from_field(field, **kwargs) @classmethod @@ -211,7 +214,7 @@ def yseries(self): @property def y(self): """The y data is the data attribute of the field""" - return self.field.data[0] + return self.field.data @property def y_name(self): @@ -220,11 +223,86 @@ def y_name(self): @property def tseries(self): - """The TimeSeries is the second of the axes_series of the field""" + """The TimeSeries of a spectrum is a single point [0] and its tstamp""" + return TimeSeries( + name="time / [s]", unit_name="s", data=np.array([0]), tstamp=self.tstamp + ) + + def __add__(self, other): + """Adding spectra makes a (2)x(N_x) SpectrumSeries. self comes before other.""" + if not self.x == other.x: # FIXME: Some depreciation here. How else? + raise BuildError( + "can't add spectra with different `x`. " + "Consider the function `append_spectra` instead." + ) + t = np.array([0, other.tstamp - self.tstamp]) + tseries = TimeSeries( + name="time / [s]", unit_name="s", data=t, tstamp=self.tstamp + ) + new_field = Field( + name=self.name, + unit_name=self.field.unit_name, + data=np.array([self.y, other.y]), + axes_series=[tseries, self.xseries], + ) + spectrum_series_as_dict = self.as_dict() + spectrum_series_as_dict["field"] = new_field + del spectrum_series_as_dict["field_id"] + + return SpectrumSeries.from_dict(spectrum_series_as_dict) + + +class SpectrumSeries(Spectrum): + @property + def yseries(self): + # Should this return an average or would that be counterintuitive? + raise BuildError(f"{self} has no single y-series. Index it to get a Spectrum.") + + @property + def tseries(self): + """The TimeSeries of a SectrumSeries is the 0'th axis of its field. + Note that its data is not sorted! + """ + return self.field.axes_series[0] + + @property + def t(self): + """The time array of a SectrumSeries is the data of its tseries. + Note that it it is not sorted! + """ + return self.tseries.data + + @property + def xseries(self): + """The x-axis DataSeries of a SectrumSeries is the 1'st axis of its field""" return self.field.axes_series[1] + def __getitem__(self, key): + """Indexing a SpectrumSeries with an int n returns its n'th spectrum""" + if isinstance(key, int): + spectrum_as_dict = self.as_dict() + del spectrum_as_dict["field_id"] + spectrum_as_dict["field"] = Field( + name=self.y_name, + unit_name=self.field.unit_name, + data=self.y[key], + axes_series=[self.xseries], + ) + spectrum_as_dict["tstamp"] = self.tstamp + self.t[key] + return Spectrum.from_dict(spectrum_as_dict) + raise KeyError + @property - def tstamp(self): - """The value with respect to epoch of the field's single time point""" - tseries = self.tseries - return tseries.data[0] + tseries.tstamp + def y_average(self): + return np.mean(self.y, axis=0) + + @property + def plotter(self): + """The default plotter for Measurement is ValuePlotter.""" + if not self._plotter: + from .plotters.spectrum_plotter import SpectrumSeriesPlotter + + # FIXME: I had to import here to avoid running into circular import issues + + self._plotter = SpectrumSeriesPlotter(spectrum_series=self) + return self._plotter diff --git a/src/ixdat/techniques/spectroelectrochemistry.py b/src/ixdat/techniques/spectroelectrochemistry.py index 2e2c6679..ddfe2157 100644 --- a/src/ixdat/techniques/spectroelectrochemistry.py +++ b/src/ixdat/techniques/spectroelectrochemistry.py @@ -3,6 +3,7 @@ from ..data_series import Field import numpy as np from scipy.interpolate import interp1d +from ..spectra import SpectrumSeries class SpectroECMeasurement(ECMeasurement): @@ -12,8 +13,16 @@ def reference_spectrum(self): @property def spectra(self): + """The Field that is the spectra of the SEC Measurement""" return self["spectra"] + @property + def spectrum_series(self): + """The SpectrumSeries that is the spectra of the SEC Measurement""" + return SpectrumSeries.from_field( + self.spectra, tstamp=self.tstamp, name=self.name + " spectra" + ) + @property def wavelength(self): return self.spectra.axes_series[1] @@ -31,10 +40,12 @@ def plotter(self): self._plotter = SECPlotter(measurement=self) self.plot_waterfall = self._plotter.plot_waterfall + # FIXME: The above line is in __init__ in other classes. return self._plotter def calc_dOD(self, V_ref=None): + """Calculate the optical density with respect to a given reference potential""" counts = self.spectra.data if V_ref: counts_interpolater = interp1d(self.v, counts, axis=0) @@ -49,3 +60,32 @@ def calc_dOD(self, V_ref=None): data=dOD, ) return dOD_series + + def get_spectrum(self, V=None, t=None, index=None): + if V and V in self.v: + index = int(np.argmax(self.v == V)) + elif t and t in self.t: + index = int(np.argmax(self.t == t)) + if index: + return self.spectrum_series[index] + counts = self.spectra.data + counts_interpolater = interp1d(self.v, counts, axis=0) + y = counts_interpolater(V) + field = Field( + data=y, + name=self.spectra.name, + unit_name=self.spectra.unit_name, + axes_series=[self.wavelength], + ) + return Spectrum.from_field(field, tstamp=self.tstamp) + + def get_dOD_spectrum(self, V=None, t=None, index=None, V_ref=None): + spectrum_ref = self.get_spectrum(V=V_ref) + spectrum = self.get_spectrum(V=V, t=t, index=index) + field = Field( + data=np.log10(spectrum.y / spectrum_ref.y), + name="$\Delta$ OD", + unit_name="", + axes_series=[self.wavelength], + ) + return Spectrum.from_field(field) From 6b74163f3a3ba3f5f568ecf9f2d2e0f173aa9205 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Sun, 11 Jul 2021 16:19:43 +0100 Subject: [PATCH 066/118] sec plot_vs_potential; use SpectrumSeriesPlotter --- .../reader_testers/test_msrh_sec_reader.py | 7 +- src/ixdat/plotters/sec_plotter.py | 115 +++++++++--------- src/ixdat/plotters/spectrum_plotter.py | 101 +++++++++++---- src/ixdat/techniques/analysis_tools.py | 2 +- 4 files changed, 142 insertions(+), 83 deletions(-) diff --git a/development_scripts/reader_testers/test_msrh_sec_reader.py b/development_scripts/reader_testers/test_msrh_sec_reader.py index d8798e70..937cff78 100644 --- a/development_scripts/reader_testers/test_msrh_sec_reader.py +++ b/development_scripts/reader_testers/test_msrh_sec_reader.py @@ -17,7 +17,7 @@ axes = sec_meas.plot_measurement( V_ref=0.66, cmap_name="jet", - make_colorbar=False, + make_colorbar=True, ) ax = sec_meas.plot_waterfall( @@ -26,4 +26,9 @@ make_colorbar=True, ) +axes2 = sec_meas.plot_vs_potential(V_ref=0.66, cmap_name="jet", make_colorbar=False) +axes2 = sec_meas.plot_vs_potential( + V_ref=0.66, vspan=[0.5, 2], cmap_name="jet", make_colorbar=False +) + sec_meas.get_dOD_spectrum(V=1.5, V_ref=1.2).plot(color="k") diff --git a/src/ixdat/plotters/sec_plotter.py b/src/ixdat/plotters/sec_plotter.py index daa74f0e..ef2472f1 100644 --- a/src/ixdat/plotters/sec_plotter.py +++ b/src/ixdat/plotters/sec_plotter.py @@ -3,6 +3,7 @@ from matplotlib import pyplot as plt from .base_mpl_plotter import MPLPlotter from .ec_plotter import ECPlotter +from .spectrum_plotter import SpectrumSeriesPlotter class SECPlotter(MPLPlotter): @@ -15,6 +16,10 @@ def __init__(self, measurement=None): """Initiate the plotter with its default Meausurement to plot""" self.measurement = measurement self.ec_plotter = ECPlotter(measurement=measurement) + self.spectrum_series_plotter = SpectrumSeriesPlotter( + spectrum_series=self.measurement + # FIXME: Maybe SpectrumSeries should inherit from Measurement? + ) def plot_measurement( self, @@ -43,72 +48,72 @@ def plot_measurement( ) dOD_series = measurement.calc_dOD(V_ref=V_ref) - tseries = dOD_series.axes_series[0] - t = tseries.data - wlseries = dOD_series.axes_series[1] - wl = wlseries.data - dOD_data = dOD_series.data - - if tspan: - t_mask = np.logical_and(tspan[0] < t, t < tspan[-1]) - t = t[t_mask] - dOD_data = dOD_data[t_mask, :] - if wlspan: - wl_mask = np.logical_and(wlspan[0] < wl, wl < wlspan[-1]) - wl = wl[wl_mask] - dOD_data = dOD_data[:, wl_mask] - - axes[0].imshow( - dOD_data.swapaxes(0, 1), - cmap=cmap_name, - aspect="auto", - extent=(t[0], t[-1], wl[0], wl[-1]), - ) - - axes[0].set_xlabel( - (measurement.t_str if hasattr(measurement, "t_str") else None) - or tseries.name + axes[0] = self.spectrum_series_plotter.heat_plot( + field=dOD_series, + tspan=tspan, + xspan=wlspan, + ax=axes[0], + cmap_name=cmap_name, + make_colorbar=make_colorbar, ) - axes[0].set_ylabel(wlseries.name) if make_colorbar: - cmap = mpl.cm.get_cmap(cmap_name) - norm = mpl.colors.Normalize(vmin=np.min(dOD_data), vmax=np.max(dOD_data)) - cb = plt.colorbar( - mpl.cm.ScalarMappable(norm=norm, cmap=cmap), - ax=[axes[0], axes[1]], - use_gridspec=True, - anchor=(0.75, 0), - ) - cb.set_label("$\Delta$ opdical density") + pass # TODO: adjust EC plot to be same width as heat plot despite colorbar. + return axes def plot_waterfall( self, measurement=None, cmap_name="jet", make_colorbar=True, V_ref=0.66, ax=None ): measurement = measurement or self.measurement - if not ax: - ax = self.new_ax() dOD = measurement.calc_dOD(V_ref=V_ref) - dOD_data = dOD.data - v = measurement.v - wl = measurement.wl - cmap = mpl.cm.get_cmap(cmap_name) - norm = mpl.colors.Normalize(vmin=np.min(v), vmax=np.max(v)) - - for i, v_i in enumerate(v): - spec = dOD_data[i] - color = cmap(norm(v_i)) - ax.plot(wl, spec, color=color) + return self.spectrum_series_plotter.plot_waterfall( + field=dOD, + cmap_name=cmap_name, + make_colorbar=make_colorbar, + ax=ax, + vs=measurement.V_str, + ) - ax.set_xlabel(measurement.wavelength.name) - ax.set_ylabel(dOD.name) + def plot_vs_potential( + self, + measurement=None, + tspan=None, + vspan=None, + V_str=None, + J_str=None, + axes=None, + wlspan=None, + V_ref=None, + cmap_name="inferno", + make_colorbar=False, + **kwargs, + ): + measurement = measurement or self.measurement - if make_colorbar: - cb = plt.colorbar(mpl.cm.ScalarMappable(norm=norm, cmap=cmap), ax=ax) - cb.set_label(measurement.potential.name) + if not axes: + axes = self.new_two_panel_axes( + n_bottom=1, + n_top=1, + emphasis="top", + ) - return ax + self.ec_plotter.plot_vs_potential( + measurement=measurement, + tspan=tspan, + V_str=V_str, + J_str=J_str, + ax=axes[1], + ) - def plot_vs_potential(self, *args, **kwargs): - return self.ec_plotter.plot_vs_potential(*args, **kwargs) + dOD_series = measurement.calc_dOD(V_ref=V_ref) + axes[0] = self.spectrum_series_plotter.heat_plot_vs( + field=dOD_series, + vspan=vspan, + xspan=wlspan, + ax=axes[0], + cmap_name=cmap_name, + make_colorbar=make_colorbar, + vs=V_str or measurement.V_str, + ) + axes[1].set_xlim(axes[0].get_xlim()) diff --git a/src/ixdat/plotters/spectrum_plotter.py b/src/ixdat/plotters/spectrum_plotter.py index acee9044..673ff739 100644 --- a/src/ixdat/plotters/spectrum_plotter.py +++ b/src/ixdat/plotters/spectrum_plotter.py @@ -38,23 +38,63 @@ def plot_average(self, spectrum_series=None, ax=None, **kwargs): def heat_plot( self, spectrum_series=None, + field=None, tspan=None, xspan=None, ax=None, cmap_name="inferno", make_colorbar=False, ): - field = spectrum_series.field - tseries = field.axes_series[0] - t = tseries.data + return self.heat_plot_vs( + spectrum_series=spectrum_series, + field=field, + vspan=tspan, + xspan=xspan, + ax=ax, + cmap_name=cmap_name, + make_colorbar=make_colorbar, + vs="t", + ) + + def heat_plot_vs( + self, + spectrum_series=None, + field=None, + vspan=None, + xspan=None, + ax=None, + cmap_name="inferno", + make_colorbar=False, + vs=None, + ): + spectrum_series = spectrum_series or self.spectrum_series + field = field or spectrum_series.field + xseries = field.axes_series[1] x = xseries.data - data = field.data + tseries = field.axes_series[0] + + v_name = vs + if vs in ("t", tseries.tseries.name): + v = tseries.t + if hasattr(spectrum_series, "t_str") and spectrum_series.t_str: + v_name = spectrum_series.t_str + else: + v = spectrum_series.grab_for_t(vs, t=tseries.t) - if tspan: - t_mask = np.logical_and(tspan[0] < t, t < tspan[-1]) - t = t[t_mask] - data = data[t_mask, :] + data = field.data + # ^ FIXME: The heat plot will be distorted if spectra are not taken at even + # spacing on the "vs" variable. They will be especially meaningless if + # the v variable itself is not always increasing or decreasing. + + if vspan: + v_mask = np.logical_and(vspan[0] < v, v < vspan[-1]) + v = v[v_mask] + data = data[v_mask, :] + if (v[0] < v[-1]) != (vspan[0] < vspan[-1]): # this is an XOR. + # Then we need to plot the data against v in the reverse direction: + v = np.flip(v, axis=0) + data = np.flip(data, axis=0) if xspan: wl_mask = np.logical_and(xspan[0] < x, x < xspan[-1]) x = x[wl_mask] @@ -64,13 +104,9 @@ def heat_plot( data.swapaxes(0, 1), cmap=cmap_name, aspect="auto", - extent=(t[0], t[-1], x[0], x[-1]), - ) - - ax.set_xlabel( - (spectrum_series.t_str if hasattr(spectrum_series, "t_str") else None) - or tseries.name + extent=(v[0], v[-1], x[0], x[-1]), ) + ax.set_xlabel(v_name) ax.set_ylabel(xseries.name) if make_colorbar: cmap = mpl.cm.get_cmap(cmap_name) @@ -85,29 +121,42 @@ def heat_plot( return ax def plot_waterfall( - self, spectrum_series=None, cmap_name="jet", make_colorbar=True, ax=None + self, + spectrum_series=None, + field=None, + cmap_name="jet", + make_colorbar=True, + vs=None, + ax=None, ): spectrum_series = spectrum_series or self.spectrum_series - if not ax: - ax = self.new_ax() - field = spectrum_series.field + field = field or spectrum_series.field + data = field.data - t = spectrum_series.t - wl = spectrum_series.wl + t = field.axes_series[0].t + x = field.axes_series[1].data + + if vs: + v = spectrum_series.grab_for_t(vs, t=t) + else: + v = t cmap = mpl.cm.get_cmap(cmap_name) - norm = mpl.colors.Normalize(vmin=np.min(t), vmax=np.max(t)) + norm = mpl.colors.Normalize(vmin=np.min(v), vmax=np.max(v)) - for i, v_i in enumerate(t): + if not ax: + ax = self.new_ax() + + for i, v_i in enumerate(v): spec = data[i] color = cmap(norm(v_i)) - ax.plot(wl, spec, color=color) + ax.plot(x, spec, color=color) - ax.set_xlabel(spectrum_series.x_name) - ax.set_ylabel() + ax.set_xlabel(field.axes_series[1].name) + ax.set_ylabel(field.name) if make_colorbar: cb = plt.colorbar(mpl.cm.ScalarMappable(norm=norm, cmap=cmap), ax=ax) - cb.set_label(spectrum_series.potential.name) + cb.set_label(vs) return ax diff --git a/src/ixdat/techniques/analysis_tools.py b/src/ixdat/techniques/analysis_tools.py index 3077b6e5..883a2d06 100644 --- a/src/ixdat/techniques/analysis_tools.py +++ b/src/ixdat/techniques/analysis_tools.py @@ -192,5 +192,5 @@ def error(t_tot): t_total_guess = (max(v) - min(v)) / dvdt result = minimize(error, np.array(t_total_guess)) - t_total = result.x + t_total = result.x[0] return np.linspace(0, t_total, v.size) From 6475741b70fc23cda5392406f2ae492f83c74457 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Sun, 11 Jul 2021 18:30:14 +0100 Subject: [PATCH 067/118] SEC potential decay --- .../test_msrh_sec_decay_reader.py | 48 ++++++++ .../reader_testers/test_msrh_sec_reader.py | 6 +- src/ixdat/plotters/sec_plotter.py | 6 +- src/ixdat/readers/__init__.py | 3 +- src/ixdat/readers/msrh_sec.py | 107 ++++++++++++++++-- .../techniques/spectroelectrochemistry.py | 18 ++- 6 files changed, 172 insertions(+), 16 deletions(-) create mode 100644 development_scripts/reader_testers/test_msrh_sec_decay_reader.py diff --git a/development_scripts/reader_testers/test_msrh_sec_decay_reader.py b/development_scripts/reader_testers/test_msrh_sec_decay_reader.py new file mode 100644 index 00000000..fd3ab336 --- /dev/null +++ b/development_scripts/reader_testers/test_msrh_sec_decay_reader.py @@ -0,0 +1,48 @@ +"""Demonstrate simple importing and plotting SEC decay data""" + +from pathlib import Path +from ixdat import Measurement + +data_dir = Path.home() / "Dropbox/ixdat_resources/test_data/sec" + +sec_meas = Measurement.read( + data_dir / "decay/PDtest-1.35-1OSP-SP.csv", + path_to_ref_spec_file=data_dir / "WL.csv", + path_to_t_V_file=data_dir / "decay/PDtest-1.35-1OSP-E-t.csv", + path_to_t_J_file=data_dir / "decay/PDtest-1.35-1OSP-J-t.csv", + tstamp=1, + reader="msrh_sec_decay", +) + +axes = sec_meas.plot_measurement( + # V_ref=0.66, # can't do a V_ref for this as can't interpolate on potential.. + # So OD will be calculated using the reference spectrum in WL.csv + cmap_name="jet", + make_colorbar=False, +) +axes[0].get_figure().savefig("decay_vs_t.png") + +ax_w = sec_meas.plot_waterfall() +ax_w.get_figure().savefig("decay_waterfall.png") + +ref_spec = sec_meas.reference_spectrum +resting_spec = sec_meas.get_spectrum(t=5) # 5 seconds in, i.e. before the pulse +working_spec = sec_meas.get_spectrum(t=20) # during the pulse. +decaying_spec = sec_meas.get_spectrum(t=40) # after the pulse. + +ax = resting_spec.plot(color="k", label="resting") +working_spec.plot(color="r", label="working", ax=ax) +decaying_spec.plot(color="b", label="decaying", ax=ax) +ref_spec.plot(color="0.5", linestyle="--", label="reference", ax=ax) +ax.legend() +ax.get_figure().savefig("select raw spectra.png") + +resting_OD_spec = sec_meas.get_dOD_spectrum(t=5) # 5 seconds in, i.e. before the pulse +working_OD_spec = sec_meas.get_dOD_spectrum(t=20) # during the pulse. +decaying_OD_spec = sec_meas.get_dOD_spectrum(t=40) # after the pulse. + +ax_OD = resting_OD_spec.plot(color="k", label="resting") +working_OD_spec.plot(color="r", label="working", ax=ax_OD) +decaying_OD_spec.plot(color="b", label="decaying", ax=ax_OD) +ax_OD.legend() +ax_OD.get_figure().savefig("select OD spectra.png") diff --git a/development_scripts/reader_testers/test_msrh_sec_reader.py b/development_scripts/reader_testers/test_msrh_sec_reader.py index 937cff78..d4b22b2d 100644 --- a/development_scripts/reader_testers/test_msrh_sec_reader.py +++ b/development_scripts/reader_testers/test_msrh_sec_reader.py @@ -7,8 +7,8 @@ sec_meas = Measurement.read( data_dir / "test-7SEC.csv", - path_to_wl_file=data_dir / "WL.csv", - path_to_jv_file=data_dir / "test-7_JV.csv", + path_to_ref_spec_file=data_dir / "WL.csv", + path_to_V_J_file=data_dir / "test-7_JV.csv", scan_rate=1, tstamp=1, reader="msrh_sec", @@ -30,5 +30,5 @@ axes2 = sec_meas.plot_vs_potential( V_ref=0.66, vspan=[0.5, 2], cmap_name="jet", make_colorbar=False ) - +axes2[0].get_figure().savefig("sec_vs_potential.png") sec_meas.get_dOD_spectrum(V=1.5, V_ref=1.2).plot(color="k") diff --git a/src/ixdat/plotters/sec_plotter.py b/src/ixdat/plotters/sec_plotter.py index ef2472f1..28e379ee 100644 --- a/src/ixdat/plotters/sec_plotter.py +++ b/src/ixdat/plotters/sec_plotter.py @@ -59,10 +59,12 @@ def plot_measurement( if make_colorbar: pass # TODO: adjust EC plot to be same width as heat plot despite colorbar. + axes[1].set_xlim(axes[0].get_xlim()) + return axes def plot_waterfall( - self, measurement=None, cmap_name="jet", make_colorbar=True, V_ref=0.66, ax=None + self, measurement=None, cmap_name="jet", make_colorbar=True, V_ref=None, ax=None ): measurement = measurement or self.measurement dOD = measurement.calc_dOD(V_ref=V_ref) @@ -104,6 +106,7 @@ def plot_vs_potential( V_str=V_str, J_str=J_str, ax=axes[1], + **kwargs, ) dOD_series = measurement.calc_dOD(V_ref=V_ref) @@ -117,3 +120,4 @@ def plot_vs_potential( vs=V_str or measurement.V_str, ) axes[1].set_xlim(axes[0].get_xlim()) + return axes diff --git a/src/ixdat/readers/__init__.py b/src/ixdat/readers/__init__.py index f6b0b2ca..c0ba00e4 100644 --- a/src/ixdat/readers/__init__.py +++ b/src/ixdat/readers/__init__.py @@ -24,7 +24,7 @@ from .ec_ms_pkl import EC_MS_CONVERTER # spectroelectrochemistry -from .msrh_sec import MsrhSECReader +from .msrh_sec import MsrhSECReader, MsrhSECDecayReader READER_CLASSES = { "ixdat": IxdatCSVReader, @@ -38,4 +38,5 @@ "zilien_spec": ZilienSpectrumReader, "EC_MS": EC_MS_CONVERTER, "msrh_sec": MsrhSECReader, + "msrh_sec_decay": MsrhSECDecayReader, } diff --git a/src/ixdat/readers/msrh_sec.py b/src/ixdat/readers/msrh_sec.py index 139417df..0a6612a8 100644 --- a/src/ixdat/readers/msrh_sec.py +++ b/src/ixdat/readers/msrh_sec.py @@ -11,8 +11,8 @@ class MsrhSECReader: def read( self, path_to_file, - path_to_wl_file, - path_to_jv_file, + path_to_ref_spec_file, + path_to_V_J_file, scan_rate, tstamp=None, cls=None, @@ -23,12 +23,12 @@ def read( measurement_class = cls path_to_file = Path(path_to_file) - path_to_wl_file = Path(path_to_wl_file) - path_to_jv_file = Path(path_to_jv_file) + path_to_ref_spec_file = Path(path_to_ref_spec_file) + path_to_V_J_file = Path(path_to_V_J_file) sec_df = pd.read_csv(path_to_file) - ref_df = pd.read_csv(path_to_wl_file, names=["wavelength", "counts"]) - jv_df = pd.read_csv(path_to_jv_file, names=["v", "j"]) + ref_df = pd.read_csv(path_to_ref_spec_file, names=["wavelength", "counts"]) + jv_df = pd.read_csv(path_to_V_J_file, names=["v", "j"]) spectra = sec_df.to_numpy()[:, 1:].swapaxes(0, 1) @@ -42,14 +42,14 @@ def read( "reference", "counts", axes_series=[wl_series], - data=np.array([ref_signal]), + data=np.array(ref_signal), ) v = jv_df["v"].to_numpy() j = jv_df["j"].to_numpy() excess_jv_points = len(v) - spectra.shape[0] v = v[:-excess_jv_points] - j = j[:-excess_jv_points] + j = j[:-excess_jv_points] * 1e3 # convert [A] to [mA] t = calc_t_using_scan_rate(v, dvdt=scan_rate * 1e-3) tstamp = tstamp or prompt_for_tstamp(path_to_file) @@ -82,3 +82,94 @@ def read( ) return measurement + + +class MsrhSECDecayReader: + def read( + self, + path_to_file, + path_to_ref_spec_file, + path_to_t_J_file, + path_to_t_V_file, + offset=None, + tstamp=None, + cls=None, + ): + + measurement_class = TECHNIQUE_CLASSES["S-EC"] + if issubclass(cls, measurement_class): + measurement_class = cls + + path_to_file = Path(path_to_file) + path_to_ref_spec_file = Path(path_to_ref_spec_file) + path_to_t_V_file = Path(path_to_t_V_file) + path_to_t_J_file = Path(path_to_t_J_file) + + sec_df = pd.read_csv(path_to_file) + ref_df = pd.read_csv(path_to_ref_spec_file, names=["wavelength", "counts"]) + t_V_df = pd.read_csv(path_to_t_V_file, names=["t", "V"]) + t_J_df = pd.read_csv(path_to_t_J_file, names=["t", "J"]) + + t_and_spectra = sec_df.to_numpy() + spectra = t_and_spectra[:, 1:].swapaxes(0, 1) + t_spectra = np.array([float(key) for key in sec_df.keys()])[1:] + + wl = ref_df["wavelength"].to_numpy() + excess_wl_points = len(wl) - spectra.shape[1] + wl = wl[excess_wl_points:] + ref_signal = ref_df["counts"].to_numpy()[excess_wl_points:] + + wl_series = DataSeries("wavelength / [nm]", "nm", wl) + reference = Field( + name="reference", + unit_name="counts", + axes_series=[wl_series], + data=np.array(ref_signal), + ) + + v = t_V_df["V"].to_numpy() + t_v = t_V_df["t"].to_numpy() + j = t_J_df["J"].to_numpy() * 1e3 # Convert [A] to [mA] + t_j = t_J_df["t"].to_numpy() + + if False: + # trim stuff down? + excess_t_v_points = len(v) - spectra.shape[0] + t_v = t_v[:-excess_t_v_points] + v = v[:-excess_t_v_points] + excess_t_j_points = len(j) - spectra.shape[0] + t_j = t_j[:-excess_t_j_points] + j = j[:-excess_t_j_points] + if offset: + t_j = t_j - offset # I have no idea why but there seems a 22-second offset. + + tstamp = tstamp or prompt_for_tstamp(path_to_file) + + tseries_j = TimeSeries("t for current", "s", data=t_j, tstamp=tstamp) + tseries_v = TimeSeries("t for potential", "s", data=t_v, tstamp=tstamp) + tseries_spectra = TimeSeries("t for spectra", "s", t_spectra, tstamp) + v_series = ValueSeries("raw potential / [V]", "V", v, tseries=tseries_v) + j_series = ValueSeries("raw current / [mA]", "mA", j, tseries=tseries_j) + spectra = Field( + name="spectra", + unit_name="counts", + axes_series=[tseries_spectra, wl_series], + data=spectra, + ) + series_list = [ + v_series, + j_series, + wl_series, + reference, + spectra, + ] + + measurement = measurement_class( + name=str(path_to_file), + tstamp=tstamp, + series_list=series_list, + raw_potential_names=(v_series.name,), + raw_current_names=(j_series.name,), + ) + + return measurement diff --git a/src/ixdat/techniques/spectroelectrochemistry.py b/src/ixdat/techniques/spectroelectrochemistry.py index ddfe2157..a1199c7b 100644 --- a/src/ixdat/techniques/spectroelectrochemistry.py +++ b/src/ixdat/techniques/spectroelectrochemistry.py @@ -69,8 +69,17 @@ def get_spectrum(self, V=None, t=None, index=None): if index: return self.spectrum_series[index] counts = self.spectra.data - counts_interpolater = interp1d(self.v, counts, axis=0) - y = counts_interpolater(V) + if V: + counts_interpolater = interp1d(self.v, counts, axis=0) + # FIXME: This requires that potential and spectra have same tseries! + y = counts_interpolater(V) + elif t: + t_spec = self.spectra.axes_series[0].t + counts_interpolater = interp1d(t_spec, counts, axis=0) + y = counts_interpolater(t) + else: + raise TypeError(f"Need t or v or index to select a spectrum!") + field = Field( data=y, name=self.spectra.name, @@ -80,7 +89,10 @@ def get_spectrum(self, V=None, t=None, index=None): return Spectrum.from_field(field, tstamp=self.tstamp) def get_dOD_spectrum(self, V=None, t=None, index=None, V_ref=None): - spectrum_ref = self.get_spectrum(V=V_ref) + if V_ref: + spectrum_ref = self.get_spectrum(V=V_ref) + else: + spectrum_ref = self.reference_spectrum spectrum = self.get_spectrum(V=V, t=t, index=index) field = Field( data=np.log10(spectrum.y / spectrum_ref.y), From 344790e379c7ffd2c8d8d87fca7da9025c545f76 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Mon, 12 Jul 2021 18:55:29 +0100 Subject: [PATCH 068/118] fix sign issue and v direction in sec --- .../test_msrh_sec_decay_reader.py | 21 +++++++++++++++---- .../reader_testers/test_msrh_sec_reader.py | 18 +++++++++++----- src/ixdat/readers/msrh_sec.py | 10 ++++----- .../techniques/spectroelectrochemistry.py | 4 ++-- 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/development_scripts/reader_testers/test_msrh_sec_decay_reader.py b/development_scripts/reader_testers/test_msrh_sec_decay_reader.py index fd3ab336..b5afd1f7 100644 --- a/development_scripts/reader_testers/test_msrh_sec_decay_reader.py +++ b/development_scripts/reader_testers/test_msrh_sec_decay_reader.py @@ -14,16 +14,29 @@ reader="msrh_sec_decay", ) +if True: # Replace reference with spectrum at t=5 + from ixdat.data_series import Field + + ref_spec = sec_meas.get_spectrum(t=5) + reference = Field( + name="reference", + unit_name="counts", + data=ref_spec.field.data, + axes_series=ref_spec.field.axes_series, + ) + sec_meas["reference"] = reference + axes = sec_meas.plot_measurement( # V_ref=0.66, # can't do a V_ref for this as can't interpolate on potential.. # So OD will be calculated using the reference spectrum in WL.csv cmap_name="jet", make_colorbar=False, ) -axes[0].get_figure().savefig("decay_vs_t.png") +# axes[0].get_figure().savefig("decay_vs_t.png") ax_w = sec_meas.plot_waterfall() -ax_w.get_figure().savefig("decay_waterfall.png") + +# ax_w.get_figure().savefig("decay_waterfall.png") ref_spec = sec_meas.reference_spectrum resting_spec = sec_meas.get_spectrum(t=5) # 5 seconds in, i.e. before the pulse @@ -35,7 +48,7 @@ decaying_spec.plot(color="b", label="decaying", ax=ax) ref_spec.plot(color="0.5", linestyle="--", label="reference", ax=ax) ax.legend() -ax.get_figure().savefig("select raw spectra.png") +# ax.get_figure().savefig("select raw spectra.png") resting_OD_spec = sec_meas.get_dOD_spectrum(t=5) # 5 seconds in, i.e. before the pulse working_OD_spec = sec_meas.get_dOD_spectrum(t=20) # during the pulse. @@ -45,4 +58,4 @@ working_OD_spec.plot(color="r", label="working", ax=ax_OD) decaying_OD_spec.plot(color="b", label="decaying", ax=ax_OD) ax_OD.legend() -ax_OD.get_figure().savefig("select OD spectra.png") +# ax_OD.get_figure().savefig("select OD spectra.png") diff --git a/development_scripts/reader_testers/test_msrh_sec_reader.py b/development_scripts/reader_testers/test_msrh_sec_reader.py index d4b22b2d..2e126b3a 100644 --- a/development_scripts/reader_testers/test_msrh_sec_reader.py +++ b/development_scripts/reader_testers/test_msrh_sec_reader.py @@ -3,6 +3,11 @@ from pathlib import Path from ixdat import Measurement +from matplotlib import pyplot as plt + +plt.close("all") + + data_dir = Path.home() / "Dropbox/ixdat_resources/test_data/sec" sec_meas = Measurement.read( @@ -15,20 +20,23 @@ ) axes = sec_meas.plot_measurement( - V_ref=0.66, + V_ref=0.4, cmap_name="jet", make_colorbar=True, ) ax = sec_meas.plot_waterfall( - V_ref=0.66, + V_ref=0.4, cmap_name="jet", make_colorbar=True, ) axes2 = sec_meas.plot_vs_potential(V_ref=0.66, cmap_name="jet", make_colorbar=False) axes2 = sec_meas.plot_vs_potential( - V_ref=0.66, vspan=[0.5, 2], cmap_name="jet", make_colorbar=False + V_ref=0.4, vspan=[0.5, 2], cmap_name="jet", make_colorbar=False ) -axes2[0].get_figure().savefig("sec_vs_potential.png") -sec_meas.get_dOD_spectrum(V=1.5, V_ref=1.2).plot(color="k") + +ax = sec_meas.get_dOD_spectrum(V_ref=0.4, V=0.8).plot(color="b", label="species 1") +sec_meas.get_dOD_spectrum(V_ref=0.8, V=1.2).plot(color="g", ax=ax, label="species 2") +sec_meas.get_dOD_spectrum(V_ref=1.2, V=1.49).plot(color="r", ax=ax, label="species 3") +ax.legend() diff --git a/src/ixdat/readers/msrh_sec.py b/src/ixdat/readers/msrh_sec.py index 0a6612a8..1b269813 100644 --- a/src/ixdat/readers/msrh_sec.py +++ b/src/ixdat/readers/msrh_sec.py @@ -45,11 +45,11 @@ def read( data=np.array(ref_signal), ) - v = jv_df["v"].to_numpy() - j = jv_df["j"].to_numpy() - excess_jv_points = len(v) - spectra.shape[0] - v = v[:-excess_jv_points] - j = j[:-excess_jv_points] * 1e3 # convert [A] to [mA] + v_0 = jv_df["v"].to_numpy() + j_0 = jv_df["j"].to_numpy() + # v = v[:-excess_jv_points] # WRONG!!! + v = np.array([float(key) for key in sec_df.keys()])[1:] + j = np.flip(j_0)[-len(v) :] t = calc_t_using_scan_rate(v, dvdt=scan_rate * 1e-3) tstamp = tstamp or prompt_for_tstamp(path_to_file) diff --git a/src/ixdat/techniques/spectroelectrochemistry.py b/src/ixdat/techniques/spectroelectrochemistry.py index a1199c7b..34919957 100644 --- a/src/ixdat/techniques/spectroelectrochemistry.py +++ b/src/ixdat/techniques/spectroelectrochemistry.py @@ -52,7 +52,7 @@ def calc_dOD(self, V_ref=None): ref_counts = counts_interpolater(V_ref) else: ref_counts = self.reference_spectrum.y - dOD = np.log10(counts / ref_counts) + dOD = -np.log10(counts / ref_counts) # check the sign! dOD_series = Field( name="$\Delta$ O.D.", unit_name="", @@ -95,7 +95,7 @@ def get_dOD_spectrum(self, V=None, t=None, index=None, V_ref=None): spectrum_ref = self.reference_spectrum spectrum = self.get_spectrum(V=V, t=t, index=index) field = Field( - data=np.log10(spectrum.y / spectrum_ref.y), + data=-np.log10(spectrum.y / spectrum_ref.y), name="$\Delta$ OD", unit_name="", axes_series=[self.wavelength], From f75ef4e97cfba1a8ab26d029277bcd17687afbd4 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Tue, 13 Jul 2021 12:57:59 +0100 Subject: [PATCH 069/118] set_reference_spectrum(); doc strings for S-EC. --- .../test_msrh_sec_decay_reader.py | 12 +- .../reader_testers/test_msrh_sec_reader.py | 12 +- src/ixdat/plotters/sec_plotter.py | 87 +++++++++- src/ixdat/plotters/spectrum_plotter.py | 56 +++++++ src/ixdat/readers/msrh_sec.py | 155 +++++++++++++----- .../techniques/spectroelectrochemistry.py | 127 +++++++++++--- 6 files changed, 365 insertions(+), 84 deletions(-) diff --git a/development_scripts/reader_testers/test_msrh_sec_decay_reader.py b/development_scripts/reader_testers/test_msrh_sec_decay_reader.py index b5afd1f7..8db85d19 100644 --- a/development_scripts/reader_testers/test_msrh_sec_decay_reader.py +++ b/development_scripts/reader_testers/test_msrh_sec_decay_reader.py @@ -14,17 +14,7 @@ reader="msrh_sec_decay", ) -if True: # Replace reference with spectrum at t=5 - from ixdat.data_series import Field - - ref_spec = sec_meas.get_spectrum(t=5) - reference = Field( - name="reference", - unit_name="counts", - data=ref_spec.field.data, - axes_series=ref_spec.field.axes_series, - ) - sec_meas["reference"] = reference +sec_meas.set_reference_spectrum(t_ref=5) axes = sec_meas.plot_measurement( # V_ref=0.66, # can't do a V_ref for this as can't interpolate on potential.. diff --git a/development_scripts/reader_testers/test_msrh_sec_reader.py b/development_scripts/reader_testers/test_msrh_sec_reader.py index 2e126b3a..83910786 100644 --- a/development_scripts/reader_testers/test_msrh_sec_reader.py +++ b/development_scripts/reader_testers/test_msrh_sec_reader.py @@ -19,6 +19,9 @@ reader="msrh_sec", ) +sec_meas.calibrate_RE(RE_vs_RHE=0.26) # provide RE potential in [V] vs RHE +sec_meas.normalize_current(A_el=2) # provide electrode area in [cm^2] + axes = sec_meas.plot_measurement( V_ref=0.4, cmap_name="jet", @@ -33,10 +36,11 @@ axes2 = sec_meas.plot_vs_potential(V_ref=0.66, cmap_name="jet", make_colorbar=False) axes2 = sec_meas.plot_vs_potential( - V_ref=0.4, vspan=[0.5, 2], cmap_name="jet", make_colorbar=False + V_ref=0.66, vspan=[1.4, 2], cmap_name="jet", make_colorbar=False ) -ax = sec_meas.get_dOD_spectrum(V_ref=0.4, V=0.8).plot(color="b", label="species 1") -sec_meas.get_dOD_spectrum(V_ref=0.8, V=1.2).plot(color="g", ax=ax, label="species 2") -sec_meas.get_dOD_spectrum(V_ref=1.2, V=1.49).plot(color="r", ax=ax, label="species 3") +ax = sec_meas.get_dOD_spectrum(V_ref=0.66, V=1.0).plot(color="b", label="species 1") +sec_meas.get_dOD_spectrum(V_ref=1.0, V=1.45).plot(color="g", ax=ax, label="species 2") +sec_meas.get_dOD_spectrum(V_ref=1.45, V=1.75).plot(color="r", ax=ax, label="species 3") + ax.legend() diff --git a/src/ixdat/plotters/sec_plotter.py b/src/ixdat/plotters/sec_plotter.py index 28e379ee..0ad2bd3c 100644 --- a/src/ixdat/plotters/sec_plotter.py +++ b/src/ixdat/plotters/sec_plotter.py @@ -28,10 +28,37 @@ def plot_measurement( wlspan=None, axes=None, V_ref=None, + t_ref=None, cmap_name="inferno", make_colorbar=False, **kwargs, ): + """Plot an SECMeasurement in two panels with time as x-asis. + + The top panel is a heat plot with wavelength on y-axis and color representing + spectrum. At most one of V_ref and t_ref should be given, and if neither are + given the measurement's default reference_spectrum is used to calculate the + optical density. + + Args: + measurement (Measurement): The measurement to be plotted, if different from + self.measurement + tspan (timespan): The timespan of data to keep for the measurement. + wlspan (iterable): The wavelength span of spectral data to plot + axes (list of mpl.Axis): The axes to plot on. axes[0] is for the heat + plot, axes[1] for potential, and axes[2] for current. The axes are + optional and a new set of axes, where axes[1] and axes[2] are twinned on + x, are generated if not provided. + V_ref (float): potential to use as reference for calculating optical density + t_ref (float): time to use as a reference for calculating optical density + cmap_name (str): The name of the colormap to use. Defaults to "inferno", + which ranges from black through red and orange to yellow-white. "jet" + is also good. + make_colorbar (bool): Whether to make a colorbar. + FIXME: colorbar at present mis-alignes axes + kwargs: Additional key-word arguments are passed on to + ECPlotter.plot_measurement(). + """ measurement = measurement or self.measurement if not axes: @@ -47,7 +74,7 @@ def plot_measurement( **kwargs, ) - dOD_series = measurement.calc_dOD(V_ref=V_ref) + dOD_series = measurement.calc_dOD(V_ref=V_ref, t_ref=t_ref) axes[0] = self.spectrum_series_plotter.heat_plot( field=dOD_series, tspan=tspan, @@ -64,10 +91,38 @@ def plot_measurement( return axes def plot_waterfall( - self, measurement=None, cmap_name="jet", make_colorbar=True, V_ref=None, ax=None + self, + measurement=None, + ax=None, + V_ref=None, + t_ref=None, + cmap_name="jet", + make_colorbar=True, ): + """Plot an SECMeasurement as spectra colored based on potential. + + The top panel is a heat plot with wavelength on y-axis and color representing + spectrum. At most one of V_ref and t_ref should be given, and if neither are + given the measurement's default reference_spectrum is used to calculate the + optical density. + + This uses SpectrumSeriesPlotter.plot_waterfall() + + Args: + measurement (Measurement): The measurement to be plotted, if different from + self.measurement + tspan (timespan): The timespan of data to keep for the measurement. + wlspan (iterable): The wavelength span of spectral data to plot + ax (matplotlib Axis): The axes to plot on. A new one is made by default. + V_ref (float): potential to use as reference for calculating optical density + t_ref (float): time to use as a reference for calculating optical density + cmap_name (str): The name of the colormap to use. Defaults to "inferno", + which ranges from black through red and orange to yellow-white. "jet" + is also good. + make_colorbar (bool): Whether to make a colorbar. + """ measurement = measurement or self.measurement - dOD = measurement.calc_dOD(V_ref=V_ref) + dOD = measurement.calc_dOD(V_ref=V_ref, t_ref=t_ref) return self.spectrum_series_plotter.plot_waterfall( field=dOD, @@ -91,6 +146,32 @@ def plot_vs_potential( make_colorbar=False, **kwargs, ): + """Plot an SECMeasurement in two panels with potential as x-asis. + + The top panel is a heat plot with wavelength on y-axis and color representing + spectrum. At most one of V_ref and t_ref should be given, and if neither are + given the measurement's default reference_spectrum is used to calculate the + optical density. + + Args: + measurement (Measurement): The measurement to be plotted, if different from + self.measurement + tspan (timespan): The timespan of data to keep for the measurement. + vspan (timespan): The potential span of data to keep for the measurement. + V_str (str): Optional. The name of the data series to use as potential. + J_str (str): Optional. The name of the data series to use as current. + wlspan (iterable): The wavelength span of spectral data to plot + axes (list of numpy Axes): The axes to plot on. axes[0] is for the heat + plot and axes[1] for potential. New are made by default. + V_ref (float): potential to use as reference for calculating optical density + t_ref (float): time to use as a reference for calculating optical density + cmap_name (str): The name of the colormap to use. Defaults to "inferno", + which ranges from black through red and orange to yellow-white. "jet" + is also good. + make_colorbar (bool): Whether to make a colorbar. + kwargs: Additional key-word arguments are passed on to + ECPlotter.plot_vs_potential(). + """ measurement = measurement or self.measurement if not axes: diff --git a/src/ixdat/plotters/spectrum_plotter.py b/src/ixdat/plotters/spectrum_plotter.py index 673ff739..072657f1 100644 --- a/src/ixdat/plotters/spectrum_plotter.py +++ b/src/ixdat/plotters/spectrum_plotter.py @@ -5,10 +5,19 @@ class SpectrumPlotter(MPLPlotter): + """A plotter for spectrums""" + def __init__(self, spectrum=None): self.spectrum = spectrum def plot(self, spectrum=None, ax=None, **kwargs): + """Plot a spectrum as y (signal) vs x (scanning variable) + + Args: + spectrum (Spectrum): The spectrum to plot if different from self.spectrum + ax (mpl.Axis): The axis to plot on. A new one is made by default. + kwargs: additional key-word arguments are given to ax.plot() + """ spectrum = spectrum or self.spectrum if not ax: ax = self.new_ax() @@ -19,14 +28,18 @@ def plot(self, spectrum=None, ax=None, **kwargs): class SpectrumSeriesPlotter(MPLPlotter): + """A plotter for spectrum series, f.ex. spectra taken continuously over time""" + def __init__(self, spectrum_series=None): self.spectrum_series = spectrum_series @property def plot(self): + """The default plot of a SpectrumSeries is heat_plot""" return self.heat_plot def plot_average(self, spectrum_series=None, ax=None, **kwargs): + """Take an average of the spectra and plot that.""" spectrum_series = spectrum_series or self.spectrum_series if not ax: ax = self.new_ax() @@ -45,6 +58,10 @@ def heat_plot( cmap_name="inferno", make_colorbar=False, ): + """Plot with time as x, the scanning variable as y, and color as signal + + See SpectrumSeriesPlotter.heat_plot_vs(). This function calls it with vs="t". + """ return self.heat_plot_vs( spectrum_series=spectrum_series, field=field, @@ -67,6 +84,29 @@ def heat_plot_vs( make_colorbar=False, vs=None, ): + """Plot an SECMeasurement in two panels with time as x-asis. + + The top panel is a heat plot with wavelength on y-axis and color representing + spectrum. At most one of V_ref and t_ref should be given, and if neither are + given the measurement's default reference_spectrum is used to calculate the + optical density. + + Args: + spectrum_series (SpectrumSeries): The spectrum series to be plotted, if + different from self.spectrum_series. + FIXME: spectrum_series needs to actually be a Measurement to have other + series to plot against if vs isn't in field.series_axes + field (Field): The field to be plotted, if different from + spectrum_series.field + xspan (iterable): The span of the spectral data to plot + 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 "inferno", + which ranges from black through red and orange to yellow-white. "jet" + is also good. + make_colorbar (bool): Whether to make a colorbar. + FIXME: colorbar at present mis-alignes axes + vs (str): The ValueSeries or TimeSeries to plot against. + """ spectrum_series = spectrum_series or self.spectrum_series field = field or spectrum_series.field @@ -129,6 +169,22 @@ def plot_waterfall( vs=None, ax=None, ): + """Plot a SpectrumSeries as spectra colored by the value at which they are taken + + Args: + spectrum_series (SpectrumSeries): The spectrum series to be plotted, if + different from self.spectrum_series. + FIXME: spectrum_series needs to actually be a Measurement to have other + series to plot against if vs isn't in field.series_axes + field (Field): The field to be plotted, if different from + spectrum_series.field + ax (matplotlib 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 "inferno", + which ranges from black through red and orange to yellow-white. "jet" + is also good. + make_colorbar (bool): Whether to make a colorbar. + vs (str): The name of the value to use for the color scale. Defaults to time + """ spectrum_series = spectrum_series or self.spectrum_series field = field or spectrum_series.field diff --git a/src/ixdat/readers/msrh_sec.py b/src/ixdat/readers/msrh_sec.py index 1b269813..be89a749 100644 --- a/src/ixdat/readers/msrh_sec.py +++ b/src/ixdat/readers/msrh_sec.py @@ -8,6 +8,8 @@ class MsrhSECReader: + """A reader for SEC saved in three files: spectra vs v; wavelengths; current vs v""" + def read( self, path_to_file, @@ -17,53 +19,98 @@ def read( tstamp=None, cls=None, ): - - measurement_class = TECHNIQUE_CLASSES["S-EC"] - if issubclass(cls, measurement_class): - measurement_class = cls - - path_to_file = Path(path_to_file) - path_to_ref_spec_file = Path(path_to_ref_spec_file) - path_to_V_J_file = Path(path_to_V_J_file) - + """Read potential-dep. SEC data from 3 csv's to return a SpectroECMeasurement + + The function is well-commented so take a look at the source + + Args: + path_to_file (Path or str): The full path to the file containing the + spectra data. This file has voltage in the first row, and a first + column with an arbitrary counter which has to be replaced by wavelength. + path_to_ref_spec_file (Path or str): The full path to the file containing + the wavelenth data, together usually with the adsorption-free spectrum. + The length of the columns should be the same as in the spectrum data + but in practice is a few points longer. The excess points at the ends + of the columns are discarded. + path_to_V_J_file (Path or str): The full path to the file containing the + current data vs potential. The columns may be reversed in order. In the + end the potential in the spectra file will be retained and the potential + here used to interpolate the current onto the spectra file's potential. + scan_rate (float): Scan rate in [mV/s]. This is used to figure out the + measurement's time variable, as time is bizarrely not included in any + of the data files. + tstamp (float): Timestamp. If None, the user will be prompted for the + measurement start time or whether to use the file creation time. This is + necessary because tstamp is also not included in any of the files but is + central to how ixdat organizes data. If you're sure that tstamp doesn't + matter for you, put e.g. tstamp=1 to suppress the prompt. + cls (Measurement subclass): The class of measurement to return. Defaults to + SpectroECMeasurement. + """ + + # us pandas to load the data from the csv files into dataframes: sec_df = pd.read_csv(path_to_file) + # ^ Note the first row, containing potential, will become the keys. The first + # column, containing an arbitrary counter, is included in the data. ref_df = pd.read_csv(path_to_ref_spec_file, names=["wavelength", "counts"]) jv_df = pd.read_csv(path_to_V_J_file, names=["v", "j"]) + # The spectra need (i) the first colum with the arbitrary counter to be + # discarded and (ii) axes switched so that wavelength is the inner axis + # (axis=1). The outer axis (axis=0) then spans potential or, eq., time: spectra = sec_df.to_numpy()[:, 1:].swapaxes(0, 1) + # The potential comes from the keys of that data, discarding the first column: + v = np.array([float(key) for key in sec_df.keys()])[1:] + # We get time from this potential and the scan rate, with a helper function: + t = calc_t_using_scan_rate(v, dvdt=scan_rate * 1e-3) + # If they didn't provide a tstamp, we have to prompt for it. + tstamp = tstamp or prompt_for_tstamp(path_to_file) + # Ready to define the measurement's TimeSeries: + tseries = TimeSeries( + "time from scan rate", unit_name="s", data=t, tstamp=tstamp + ) + # The wavelength comes from the reference spectrum file. wl = ref_df["wavelength"].to_numpy() excess_wl_points = len(wl) - spectra.shape[1] + # ^ This is how many points to discard to line up with sec data + # (1 or 2 points in the example data). wl = wl[excess_wl_points:] ref_signal = ref_df["counts"].to_numpy()[excess_wl_points:] + # Now we're ready to define all the spectrum DataSeries: + + # wavelength is independent variable --> simple DataSeries wl_series = DataSeries("wavelength / [nm]", "nm", wl) + # The reference spectrum spans a space defined by wavelength: reference = Field( "reference", "counts", axes_series=[wl_series], - data=np.array(ref_signal), + data=ref_signal, ) - - v_0 = jv_df["v"].to_numpy() - j_0 = jv_df["j"].to_numpy() - # v = v[:-excess_jv_points] # WRONG!!! - v = np.array([float(key) for key in sec_df.keys()])[1:] - j = np.flip(j_0)[-len(v) :] - t = calc_t_using_scan_rate(v, dvdt=scan_rate * 1e-3) - - tstamp = tstamp or prompt_for_tstamp(path_to_file) - tseries = TimeSeries( - "time from scan rate", unit_name="s", data=t, tstamp=tstamp - ) - v_series = ValueSeries("raw potential / [V]", "V", v, tseries=tseries) - j_series = ValueSeries("raw current / [mA]", "mA", j, tseries=tseries) + # The spectra span a space defined by time and wavelength: spectra = Field( name="spectra", unit_name="counts", axes_series=[tseries, wl_series], data=spectra, ) + + # Now we process the current and potential: + v_0 = jv_df["v"].to_numpy() # ... but we'll actually use v from the sec data + j_0 = jv_df["j"].to_numpy() * 1e3 # 1e3 converts [A] to [mA] + if v_0[0] > v_0[-1]: # Need the potential in the EC file to be increasing: + v_0 = np.flip(v_0) + j_0 = np.flip(j_0) + # Since the "real" potential is in the sec data, we need to interpolate the + # current onto it: + j = np.interp(v, v_0, j_0) + # and now we're ready to define the electrochemical DataSeries: + v_series = ValueSeries("raw potential / [V]", "V", v, tseries=tseries) + j_series = ValueSeries("raw current / [mA]", "mA", j, tseries=tseries) + + # put all our DataSeries together: series_list = [ tseries, v_series, @@ -73,6 +120,13 @@ def read( spectra, ] + # Figure out which measurement class to return. Use S-EC unless this read + # function is provided an even more specific technique class: + measurement_class = TECHNIQUE_CLASSES["S-EC"] + if issubclass(cls, measurement_class): + measurement_class = cls + + # and initiate the measurement: measurement = measurement_class( name=str(path_to_file), tstamp=tstamp, @@ -91,19 +145,34 @@ def read( path_to_ref_spec_file, path_to_t_J_file, path_to_t_V_file, - offset=None, tstamp=None, cls=None, ): - - measurement_class = TECHNIQUE_CLASSES["S-EC"] - if issubclass(cls, measurement_class): - measurement_class = cls - - path_to_file = Path(path_to_file) - path_to_ref_spec_file = Path(path_to_ref_spec_file) - path_to_t_V_file = Path(path_to_t_V_file) - path_to_t_J_file = Path(path_to_t_J_file) + """Read time-dependent SEC data from 4 csv's to return a SpectroECMeasurement + + The function works in a very similar way to MsrhSECReader.read(). + + Args: + path_to_file (Path or str): The full path to the file containing the + spectra data. This file has time in the first row, and a first + column with an arbitrary counter which has to be replaced by wavelength. + path_to_ref_spec_file (Path or str): The full path to the file containing + the wavelenth data, together usually with the adsorption-free spectrum. + The length of the columns should be the same as in the spectrum data + but in practice is a few points longer. The excess points at the ends + of the columns are discarded. + path_to_t_V_file (Path or str): The full path to the file containing the + potential data vs time. + path_to_t_J_file (Path or str): The full path to the file containing the + current data vs time. + tstamp (float): Timestamp. If None, the user will be prompted for the + measurement start time or whether to use the file creation time. This is + necessary because tstamp is also not included in any of the files but is + central to how ixdat organizes data. If you're sure that tstamp doesn't + matter for you, put e.g. tstamp=1 to suppress the prompt. + cls (Measurement subclass): The class of measurement to return. Defaults to + SpectroECMeasurement. + """ sec_df = pd.read_csv(path_to_file) ref_df = pd.read_csv(path_to_ref_spec_file, names=["wavelength", "counts"]) @@ -132,17 +201,6 @@ def read( j = t_J_df["J"].to_numpy() * 1e3 # Convert [A] to [mA] t_j = t_J_df["t"].to_numpy() - if False: - # trim stuff down? - excess_t_v_points = len(v) - spectra.shape[0] - t_v = t_v[:-excess_t_v_points] - v = v[:-excess_t_v_points] - excess_t_j_points = len(j) - spectra.shape[0] - t_j = t_j[:-excess_t_j_points] - j = j[:-excess_t_j_points] - if offset: - t_j = t_j - offset # I have no idea why but there seems a 22-second offset. - tstamp = tstamp or prompt_for_tstamp(path_to_file) tseries_j = TimeSeries("t for current", "s", data=t_j, tstamp=tstamp) @@ -157,6 +215,9 @@ def read( data=spectra, ) series_list = [ + tseries_j, + tseries_v, + tseries_spectra, v_series, j_series, wl_series, @@ -164,6 +225,10 @@ def read( spectra, ] + measurement_class = TECHNIQUE_CLASSES["S-EC"] + if issubclass(cls, measurement_class): + measurement_class = cls + measurement = measurement_class( name=str(path_to_file), tstamp=tstamp, diff --git a/src/ixdat/techniques/spectroelectrochemistry.py b/src/ixdat/techniques/spectroelectrochemistry.py index 34919957..e0c6bfa9 100644 --- a/src/ixdat/techniques/spectroelectrochemistry.py +++ b/src/ixdat/techniques/spectroelectrochemistry.py @@ -7,9 +7,44 @@ class SpectroECMeasurement(ECMeasurement): + def __init__(self, *args, **kwargs): + """Initialize an SEC measurement. All args and kwargs go to ECMeasurement.""" + ECMeasurement.__init__(self, *args, **kwargs) + self._reference_spectrum = None + self.plot_waterfall = self._plotter.plot_waterfall + @property def reference_spectrum(self): - return Spectrum.from_field(self["reference"]) + """The spectrum which will by default be used to calculate dOD""" + if not self._reference_spectrum or self._reference_spectrum == "reference": + self._reference_spectrum = Spectrum.from_field(self["reference"]) + return self._reference_spectrum + + def set_reference_spectrum( + self, + spectrum=None, + t_ref=None, + V_ref=None, + ): + """Set the spectrum used as the reference when calculating dOD. + + Args: + spectrum (Spectrum or str): If a Spectrum is given, it becomes the reference + spectrum. The string "reference" can be given to make the reference + spectrum become (via the reference_spectrum property) one that the + measurement was loaded with (evt. for definition of wavelengths). + t_ref (float): The time (with respect to self.tstamp) to use as the + reference spectrum + V_ref (float): The potential to use as the reference spectrum. This will + only work if the potential is monotonically increasing. + """ + if (not spectrum) and t_ref: + spectrum = self.get_spectrum(t=t_ref) + if (not spectrum) and V_ref: + spectrum = self.get_spectrum(V=V_ref) + if not spectrum: + raise ValueError("must provide a spectrum, t_ref, or V_ref!") + self._reference_spectrum = spectrum @property def spectra(self): @@ -25,34 +60,42 @@ def spectrum_series(self): @property def wavelength(self): + """A DataSeries with the wavelengths for the SEC spectra""" return self.spectra.axes_series[1] @property def wl(self): + """A numpy array with the wavelengths in [nm] for the SEC spectra""" return self.wavelength.data @property def plotter(self): - """The default plotter for ECMeasurement is ECPlotter""" + """The default plotter for SpectroECMeasurement is SECPlotter""" if not self._plotter: from ..plotters.sec_plotter import SECPlotter self._plotter = SECPlotter(measurement=self) - self.plot_waterfall = self._plotter.plot_waterfall - # FIXME: The above line is in __init__ in other classes. - return self._plotter - def calc_dOD(self, V_ref=None): - """Calculate the optical density with respect to a given reference potential""" + def calc_dOD(self, V_ref=None, t_ref=None, index_ref=None): + """Calculate the optical density with respect to a reference + + Provide at most one of V_ref, t_ref, or index. If none are provided the default + reference spectrum (self.reference_spectrum) will be used. + + Args: + V_ref (float): The potential at which to get the reference spectrum + t_ref (float): The time at which to get the reference spectrum + index_ref (int): The index of the reference spectrum + Return Field: the delta optical density spanning time and wavelength + """ counts = self.spectra.data - if V_ref: - counts_interpolater = interp1d(self.v, counts, axis=0) - ref_counts = counts_interpolater(V_ref) + if V_ref or t_ref: + ref_spec = self.get_spectrum(V=V_ref, t=t_ref, index=index_ref) else: - ref_counts = self.reference_spectrum.y - dOD = -np.log10(counts / ref_counts) # check the sign! + ref_spec = self.reference_spectrum + dOD = -np.log10(counts / ref_spec.y) dOD_series = Field( name="$\Delta$ O.D.", unit_name="", @@ -62,23 +105,42 @@ def calc_dOD(self, V_ref=None): return dOD_series def get_spectrum(self, V=None, t=None, index=None): - if V and V in self.v: + """Return the Spectrum at a given potential V, time t, or index + + Exactly one of V, t, and index should be given. If V (t) is out of the range of + self.v (self.t), then first or last spectrum will be returned. + + Args: + V (float): The potential at which to get the spectrum. Measurement.v must + be monotonically increasing for this to work. + t (float): The time at which to get the spectrum + index (int): The index of the spectrum + + Return Spectrum: The spectrum. The data is (spectrum.x, spectrum.y) + """ + if V and V in self.v: # woohoo, can skip interolation! index = int(np.argmax(self.v == V)) - elif t and t in self.t: + elif t and t in self.t: # woohoo, can skip interolation! index = int(np.argmax(self.t == t)) - if index: + if index: # then we're done: return self.spectrum_series[index] + # now, we have to interpolate: counts = self.spectra.data + end_spectra = (self.spectrum_series[0].y, self.spectrum_series[-1].y) if V: - counts_interpolater = interp1d(self.v, counts, axis=0) + counts_interpolater = interp1d( + self.v, counts, axis=0, fill_value=end_spectra, bounds_error=False + ) # FIXME: This requires that potential and spectra have same tseries! y = counts_interpolater(V) elif t: t_spec = self.spectra.axes_series[0].t - counts_interpolater = interp1d(t_spec, counts, axis=0) + counts_interpolater = interp1d( + t_spec, counts, axis=0, fill_value=end_spectra, bounds_error=False + ) y = counts_interpolater(t) else: - raise TypeError(f"Need t or v or index to select a spectrum!") + raise ValueError(f"Need t or V or index to select a spectrum!") field = Field( data=y, @@ -88,9 +150,32 @@ def get_spectrum(self, V=None, t=None, index=None): ) return Spectrum.from_field(field, tstamp=self.tstamp) - def get_dOD_spectrum(self, V=None, t=None, index=None, V_ref=None): - if V_ref: - spectrum_ref = self.get_spectrum(V=V_ref) + def get_dOD_spectrum( + self, + V=None, + t=None, + index=None, + V_ref=None, + t_ref=None, + index_ref=None, + ): + """Return the delta optical density Spectrum given a point and reference point. + + Provide exactly one of V, t, and index, and at most one of V_ref, t_ref, and + index_ref. For V and V_ref to work, the potential in the measurement must be + monotonically increasing. + + Args: + V (float): The potential at which to get the spectrum. + t (float): The time at which to get the spectrum + index (int): The index of the spectrum + V_ref (float): The potential at which to get the reference spectrum + t_ref (float): The time at which to get the reference spectrum + index_ref (int): The index of the reference spectrum + Return Spectrum: The dOD spectrum. The data is (spectrum.x, spectrum.y) + """ + if V_ref or t_ref or index_ref: + spectrum_ref = self.get_spectrum(V=V_ref, t=t_ref, index=index_ref) else: spectrum_ref = self.reference_spectrum spectrum = self.get_spectrum(V=V, t=t, index=index) From 5b00cdd0ea90ce3a7d0306f6cdc242a99c24b3d0 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Tue, 13 Jul 2021 18:29:46 +0100 Subject: [PATCH 070/118] sec exporter --- .../reader_testers/test_msrh_sec_reader.py | 4 + src/ixdat/exporters/csv_exporter.py | 94 ++++++++++++------- src/ixdat/exporters/sec_exporter.py | 42 +++++++++ src/ixdat/exporters/spectrum_exporter.py | 89 ++++++++++++++++++ src/ixdat/spectra.py | 12 +++ .../techniques/spectroelectrochemistry.py | 9 ++ 6 files changed, 218 insertions(+), 32 deletions(-) create mode 100644 src/ixdat/exporters/sec_exporter.py create mode 100644 src/ixdat/exporters/spectrum_exporter.py diff --git a/development_scripts/reader_testers/test_msrh_sec_reader.py b/development_scripts/reader_testers/test_msrh_sec_reader.py index 83910786..2c377f65 100644 --- a/development_scripts/reader_testers/test_msrh_sec_reader.py +++ b/development_scripts/reader_testers/test_msrh_sec_reader.py @@ -22,6 +22,10 @@ sec_meas.calibrate_RE(RE_vs_RHE=0.26) # provide RE potential in [V] vs RHE sec_meas.normalize_current(A_el=2) # provide electrode area in [cm^2] +sec_meas.export() + +exit() + axes = sec_meas.plot_measurement( V_ref=0.4, cmap_name="jet", diff --git a/src/ixdat/exporters/csv_exporter.py b/src/ixdat/exporters/csv_exporter.py index 6d97962f..6da15bcb 100644 --- a/src/ixdat/exporters/csv_exporter.py +++ b/src/ixdat/exporters/csv_exporter.py @@ -5,40 +5,59 @@ class CSVExporter: """The default exporter, which writes delimited measurement data row-wise to file""" - def __init__(self, measurement=None, delim=",\t", default_v_list=None): + default_v_list = None # This will typically be overwritten by inheriting Exporters + + def __init__(self, measurement=None, delim=",\t"): """Initiate the exported with a measurement (Measurement) and delimiter (str)""" self.measurement = measurement self.delim = delim - self._default_v_list = default_v_list - - @property - def default_v_list(self): - """This will typically be overwritten by inheriting Exporters""" - return self._default_v_list + self.header_lines = None + self.s_list = None + self.columns_data = None + self.path_to_file = None - def export(self, *args, **kwargs): - """Export the exporter's measurement via exporter.export_measurement()""" - return self.export_measurement(self.measurement, *args, **kwargs) - - def export_measurement(self, measurement, path_to_file, v_list=None, tspan=None): + def export(self, measurement=None, path_to_file=None, v_list=None, tspan=None): """Export a given measurement to a specified file. + To improve flexibility with inheritance, this method allocates its work to: + - CSVExporter.prepare_header_and_data() + - CSVExporter.write_header() + - CSVExporter.write_data() + Args: - measurement (Measurement): The measurement to export - path_to_file (Path): The path to the file to measure. If it has no suffix, - a .csv suffix is appended. + measurement (Measurement): The measurement to export. + Defaults to self.measurement. + path_to_file (Path): The path to the file to write. If it has no suffix, + a .csv suffix is appended. Defaults to "{measurement.name}.csv" v_list (list of str): The names of the data series to include. Defaults in CSVExporter to all VSeries and TSeries in the measurement. This default may be overwritten in inheriting exporters. tspan (timespan): The timespan to include in the file, defaults to all of it """ - columns_data = {} - s_list = [] - v_list = v_list or self.default_v_list or list(measurement.data_cols) + measurement = measurement or self.measurement + if not path_to_file: + path_to_file = input("enter name of file to export.") if isinstance(path_to_file, str): path_to_file = Path(path_to_file) if not path_to_file.suffix: path_to_file = path_to_file.with_suffix(".csv") + self.path_to_file = path_to_file + self.prepare_header_and_data(measurement, v_list, tspan) + self.prepare_column_header() + self.write_header() + self.write_data() + + def prepare_header_and_data(self, measurement, v_list, tspan): + """Prepare self.header_lines to include metadata and value-time pairs + + Args: + measurement (Measurement): The measurement being exported + v_list (list of str): The names of the ValueSeries to include + tspan (timespan): The timespan of the data to include in the export + """ + columns_data = {} + s_list = [] + v_list = v_list or self.default_v_list or list(measurement.value_names) timecols = {} for v_name in v_list: @@ -64,28 +83,39 @@ def export_measurement(self, measurement, path_to_file, v_list=None, tspan=None) + "\n" ) header_lines.append(line) + self.header_lines = header_lines + self.s_list = s_list + self.columns_data = columns_data - N_header_lines = len(header_lines) + 3 - header_lines.append(f"N_header_lines = {N_header_lines}\n") - header_lines.append("\n") + def prepare_column_header(self): + """Prepare the column header line and finish the header_lines""" + N_header_lines = len(self.header_lines) + 3 + self.header_lines.append(f"N_header_lines = {N_header_lines}\n") + self.header_lines.append("\n") col_header_line = ( - "".join([s_name + self.delim for s_name in s_list])[: -len(self.delim)] + "".join([s_name + self.delim for s_name in self.s_list])[: -len(self.delim)] + "\n" ) - header_lines.append(col_header_line) + self.header_lines.append(col_header_line) + + def write_header(self): + """Create the file and write the header lines.""" + with open(self.path_to_file, "w") as f: + f.writelines(self.header_lines) - lines = header_lines - max_length = max([len(data) for data in columns_data.values()]) + def write_data(self): + """Write data to the file one line at a time.""" + max_length = max([len(data) for data in self.columns_data.values()]) for n in range(max_length): line = "" - for s_name in s_list: - if len(columns_data[s_name]) > n: - line = line + str(columns_data[s_name][n]) + self.delim + for s_name in self.s_list: + if len(self.columns_data[s_name]) > n: + # Then there's more data to write for this series + line = line + str(self.columns_data[s_name][n]) + self.delim else: + # Then all this series is written. Just leave space. line = line + self.delim line = line + "\n" - lines.append(line) - - with open(path_to_file, "w") as f: - f.writelines(lines) + with open(self.path_to_file, "a") as f: + f.write(line) diff --git a/src/ixdat/exporters/sec_exporter.py b/src/ixdat/exporters/sec_exporter.py new file mode 100644 index 00000000..3729fb99 --- /dev/null +++ b/src/ixdat/exporters/sec_exporter.py @@ -0,0 +1,42 @@ +from .csv_exporter import CSVExporter +from .ec_exporter import ECExporter +from .spectrum_exporter import SpectrumExporter, SpectrumSeriesExporter + + +class SECExporter(CSVExporter): + """Adds to CSVExporter the export of the Field with the SEC spectra""" + + def __init__(self, measurement, delim=",\t"): + super().__init__(measurement, delim=delim) + self.reference_exporter = SpectrumExporter(measurement.reference_spectrum) + self.spectra_exporter = SpectrumSeriesExporter(measurement.spectrum_series) + + @property + def default_v_list(self): + """The default v_list for SECExporter is that from EC and tracked wavelengths""" + v_list = ( + ECExporter(measurement=self.measurement).default_v_list + + self.measurement.tracked_wavelengths + ) + return v_list + + def prepare_header_and_data(self, measurement, v_list, tspan): + """Add lines pointing to the 'spectra' and 'reference' spectrum""" + super().prepare_header_and_data(measurement, v_list, tspan) + path_to_spectra_file = self.path_to_file.parent / ( + self.path_to_file.stem + "_spectra.csv" + ) + measurement = measurement or self.measurement + self.header_lines.append(f"'spectra' in file: '{path_to_spectra_file.name}'\n") + self.spectra_exporter.export(measurement.spectrum_series, path_to_spectra_file) + path_to_reference_spectrum_file = self.path_to_file.parent / ( + self.path_to_file.stem + "_reference.csv" + ) + self.header_lines.append( + f"'reference' in file: '{path_to_reference_spectrum_file.name}'\n" + ) + self.reference_exporter.export( + measurement.reference_spectrum, path_to_reference_spectrum_file + ) + + print(f"writing {self.path_to_file}!") diff --git a/src/ixdat/exporters/spectrum_exporter.py b/src/ixdat/exporters/spectrum_exporter.py new file mode 100644 index 00000000..7eacd362 --- /dev/null +++ b/src/ixdat/exporters/spectrum_exporter.py @@ -0,0 +1,89 @@ +import pandas as pd +from collections import OrderedDict + + +class SpectrumExporter: + def __init__(self, spectrum, delim=","): + self.spectrum = spectrum + self.delim = delim + + def export(self, spectrum, path_to_file): + spectrum = spectrum or self.spectrum + df = pd.DataFrame({spectrum.x_name: spectrum.x, spectrum.y_name: spectrum.y}) + + header_lines = [] + for attr in ["name", "technique", "tstamp", "backend_name", "id"]: + line = f"{attr} = {getattr(spectrum, attr)}\n" + header_lines.append(line) + + N_header_lines = len(header_lines) + 3 + header_lines.append(f"N_header_lines = {N_header_lines}\n") + header_lines.append("\n") + # header_lines.append("".join([(key + self.delim) for key in df.keys()])) + df.to_csv(path_to_file, index=False, sep=self.delim) + + with open(path_to_file, "w") as f: + f.writelines(header_lines) + with open(path_to_file, "a") as f: + df.to_csv(f, index=False, sep=self.delim, line_terminator="") + + print(f"wrote {path_to_file}!") + + +class SpectrumSeriesExporter: + def __init__(self, spectrum_series, delim=","): + self.spectrum_series = spectrum_series + self.delim = delim + + def export(self, spectrum_series=None, path_to_file=None, spectra_as_rows=True): + + spectrum_series = spectrum_series or self.spectrum_series + + field = spectrum_series.field + data = field.data + tseries, xseries = spectrum_series.field.axes_series + t = tseries.t + tseries.tstamp - spectrum_series.tstamp + x = xseries.data + + header_lines = [] + for attr in ["name", "technique", "tstamp", "backend_name", "id"]: + line = f"{attr} = {getattr(spectrum_series, attr)}\n" + header_lines.append(line) + + header_lines.append( + f"values are '{field.name}' with units [{field.unit_name}]\n" + ) + + if spectra_as_rows: # columns are ValueSeries + data_as_list_of_tuples = [(spectrum_series.t_name, t)] + [ + (x_i, data[:, i]) for i, x_i in enumerate(x) + ] + df = pd.DataFrame(OrderedDict(data_as_list_of_tuples)) + header_lines.append( + f"first row is '{xseries.name}' with units [{xseries.unit_name}]\n" + ) + header_lines.append( + f"first column '{tseries.name}' with units [{tseries.unit_name}]\n" + ) + else: # spectra as columns. rows are ValueSeries + data_as_list_of_tuples = [(spectrum_series.x_name, x)] + [ + (t_i, data[i, :]) for i, t_i in enumerate(t) + ] + df = pd.DataFrame(OrderedDict(data_as_list_of_tuples)) + header_lines.append( + f"first row is '{tseries.name}' with units [{tseries.unit_name}]\n" + ) + header_lines.append( + f"first column is '{xseries.name}' with units [{xseries.unit_name}]\n" + ) + + N_header_lines = len(header_lines) + 3 + header_lines.append(f"N_header_lines = {N_header_lines}\n") + header_lines.append("\n") + + with open(path_to_file, "w") as f: + f.writelines(header_lines) + with open(path_to_file, "a") as f: + df.to_csv(f, index=False, sep=self.delim, line_terminator="\n") + + print(f"wrote {path_to_file}!") diff --git a/src/ixdat/spectra.py b/src/ixdat/spectra.py index 23430dfc..55db1794 100644 --- a/src/ixdat/spectra.py +++ b/src/ixdat/spectra.py @@ -272,11 +272,23 @@ def t(self): """ return self.tseries.data + @property + def t_name(self): + return self.tseries.name + @property def xseries(self): """The x-axis DataSeries of a SectrumSeries is the 1'st axis of its field""" return self.field.axes_series[1] + @property + def x(self): + return self.xseries.data + + @property + def x_name(self): + return self.xseries.name + def __getitem__(self, key): """Indexing a SpectrumSeries with an int n returns its n'th spectrum""" if isinstance(key, int): diff --git a/src/ixdat/techniques/spectroelectrochemistry.py b/src/ixdat/techniques/spectroelectrochemistry.py index e0c6bfa9..931025cd 100644 --- a/src/ixdat/techniques/spectroelectrochemistry.py +++ b/src/ixdat/techniques/spectroelectrochemistry.py @@ -4,6 +4,7 @@ import numpy as np from scipy.interpolate import interp1d from ..spectra import SpectrumSeries +from ..exporters.sec_exporter import SECExporter class SpectroECMeasurement(ECMeasurement): @@ -11,6 +12,7 @@ def __init__(self, *args, **kwargs): """Initialize an SEC measurement. All args and kwargs go to ECMeasurement.""" ECMeasurement.__init__(self, *args, **kwargs) self._reference_spectrum = None + self.tracked_wavelengths = [] self.plot_waterfall = self._plotter.plot_waterfall @property @@ -78,6 +80,13 @@ def plotter(self): return self._plotter + @property + def exporter(self): + """The default plotter for SpectroECMeasurement is SECExporter""" + if not self._exporter: + self._exporter = SECExporter(measurement=self) + return self._exporter + def calc_dOD(self, V_ref=None, t_ref=None, index_ref=None): """Calculate the optical density with respect to a reference From 93beaadb20928305b0dbcc28dffbecab002d419e Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Tue, 13 Jul 2021 21:28:12 +0100 Subject: [PATCH 071/118] IxdatCSVReader can read sec with aux files --- .../reader_testers/test_msrh_sec_reader.py | 7 +- src/ixdat/exporters/csv_exporter.py | 2 +- src/ixdat/exporters/spectrum_exporter.py | 12 +-- src/ixdat/readers/ixdat_csv.py | 85 ++++++++++++++++++- src/ixdat/spectra.py | 15 +++- .../techniques/spectroelectrochemistry.py | 5 +- 6 files changed, 109 insertions(+), 17 deletions(-) diff --git a/development_scripts/reader_testers/test_msrh_sec_reader.py b/development_scripts/reader_testers/test_msrh_sec_reader.py index 2c377f65..e45c2f0d 100644 --- a/development_scripts/reader_testers/test_msrh_sec_reader.py +++ b/development_scripts/reader_testers/test_msrh_sec_reader.py @@ -22,9 +22,10 @@ sec_meas.calibrate_RE(RE_vs_RHE=0.26) # provide RE potential in [V] vs RHE sec_meas.normalize_current(A_el=2) # provide electrode area in [cm^2] -sec_meas.export() - -exit() +export_name = "exported_sec.csv" +sec_meas.export(export_name) +ixdat_sec = Measurement.read(export_name, reader="ixdat") +ixdat_sec.plot_measurement(V_ref=0.4, cmap_name="jet") axes = sec_meas.plot_measurement( V_ref=0.4, diff --git a/src/ixdat/exporters/csv_exporter.py b/src/ixdat/exporters/csv_exporter.py index 6da15bcb..bd96ae09 100644 --- a/src/ixdat/exporters/csv_exporter.py +++ b/src/ixdat/exporters/csv_exporter.py @@ -16,7 +16,7 @@ def __init__(self, measurement=None, delim=",\t"): self.columns_data = None self.path_to_file = None - def export(self, measurement=None, path_to_file=None, v_list=None, tspan=None): + def export(self, path_to_file=None, measurement=None, v_list=None, tspan=None): """Export a given measurement to a specified file. To improve flexibility with inheritance, this method allocates its work to: diff --git a/src/ixdat/exporters/spectrum_exporter.py b/src/ixdat/exporters/spectrum_exporter.py index 7eacd362..c786601a 100644 --- a/src/ixdat/exporters/spectrum_exporter.py +++ b/src/ixdat/exporters/spectrum_exporter.py @@ -25,7 +25,7 @@ def export(self, spectrum, path_to_file): with open(path_to_file, "w") as f: f.writelines(header_lines) with open(path_to_file, "a") as f: - df.to_csv(f, index=False, sep=self.delim, line_terminator="") + df.to_csv(f, index=False, sep=self.delim, line_terminator="\n") print(f"wrote {path_to_file}!") @@ -51,7 +51,7 @@ def export(self, spectrum_series=None, path_to_file=None, spectra_as_rows=True): header_lines.append(line) header_lines.append( - f"values are '{field.name}' with units [{field.unit_name}]\n" + f"values are y='{field.name}' with units [{field.unit_name}]\n" ) if spectra_as_rows: # columns are ValueSeries @@ -60,10 +60,10 @@ def export(self, spectrum_series=None, path_to_file=None, spectra_as_rows=True): ] df = pd.DataFrame(OrderedDict(data_as_list_of_tuples)) header_lines.append( - f"first row is '{xseries.name}' with units [{xseries.unit_name}]\n" + f"first row is x='{xseries.name}' with units [{xseries.unit_name}]\n" ) header_lines.append( - f"first column '{tseries.name}' with units [{tseries.unit_name}]\n" + f"first column is t='{tseries.name}' with units [{tseries.unit_name}]\n" ) else: # spectra as columns. rows are ValueSeries data_as_list_of_tuples = [(spectrum_series.x_name, x)] + [ @@ -71,10 +71,10 @@ def export(self, spectrum_series=None, path_to_file=None, spectra_as_rows=True): ] df = pd.DataFrame(OrderedDict(data_as_list_of_tuples)) header_lines.append( - f"first row is '{tseries.name}' with units [{tseries.unit_name}]\n" + f"first row is t='{tseries.name}' with units [{tseries.unit_name}]\n" ) header_lines.append( - f"first column is '{xseries.name}' with units [{xseries.unit_name}]\n" + f"first column is x='{xseries.name}' with units [{xseries.unit_name}]\n" ) N_header_lines = len(header_lines) + 3 diff --git a/src/ixdat/readers/ixdat_csv.py b/src/ixdat/readers/ixdat_csv.py index 9ccb1b96..086d897f 100644 --- a/src/ixdat/readers/ixdat_csv.py +++ b/src/ixdat/readers/ixdat_csv.py @@ -3,9 +3,11 @@ from pathlib import Path import numpy as np import re +import pandas as pd from ..exceptions import ReadError -from ..data_series import ValueSeries, TimeSeries +from ..data_series import ValueSeries, TimeSeries, DataSeries, Field from ..measurements import Measurement +from ..spectra import Spectrum, SpectrumSeries from ..techniques import TECHNIQUE_CLASSES regular_expressions = { @@ -16,6 +18,7 @@ "id": r"id = ([0-9]+)", "timecol": r"timecol '(.+)' for: (?:'(.+)')$", "unit": r"/ [(.+)]", + "aux_file": r"'(.*)' in file: '(.*)'", } @@ -65,6 +68,7 @@ def __init__(self): self.column_names = [] self.column_data = {} self.technique = None + self.aux_series_list = [] self.measurement_class = Measurement self.file_has_been_read = False self.measurement = None @@ -97,9 +101,11 @@ def read(self, path_to_file, name=None, cls=None, **kwargs): return self.measurement self.name = name or path_to_file.name self.path_to_file = path_to_file + with open(self.path_to_file, "r") as f: for line in f: self.process_line(line) + for name in self.column_names: self.column_data[name] = np.array(self.column_data[name]) @@ -136,7 +142,7 @@ def read(self, path_to_file, name=None, cls=None, **kwargs): ) data_series_dict[column_name] = vseries - data_series_list = list(data_series_dict.values()) + data_series_list = list(data_series_dict.values()) + self.aux_series_list obj_as_dict = dict( name=self.name, technique=self.technique, @@ -196,6 +202,12 @@ def process_header_line(self, line): self.timecols[tcol] = [] for vcol in timecol_match.group(2).split("' and '"): self.timecols[tcol].append(vcol) + aux_file_match = re.search(regular_expressions["aux_file"], line) + if aux_file_match: + aux_file_name = aux_file_match.group(1) + aux_file = self.path_to_file.parent / aux_file_match.group(2) + self.read_aux_file(aux_file, name=aux_file_name) + if self.N_header_lines and self.n_line >= self.N_header_lines - 2: self.place_in_file = "column names" @@ -220,6 +232,10 @@ def process_data_line(self, line): # raise ReadError(f"can't parse value string '{value_string}'") self.column_data[name].append(value) + def read_aux_file(self, path_to_aux_file, name): + spec = IxdatSpectrumReader().read(path_to_aux_file, name=name) + self.aux_series_list += spec.series_list + def print_header(self): """Print the file header including column names. read() must be called first.""" header = "".join(self.header_lines) @@ -234,3 +250,68 @@ def get_column_unit(column_name): else: unit_name = None return unit_name + + +class IxdatSpectrumReader(IxdatCSVReader): + def read(self, path_to_file, name=None, cls=None, **kwargs): + with open(path_to_file, "r") as f: + for line in f: + if self.place_in_file == "header": + self.process_line(line) + else: + break + + df = pd.read_csv(path_to_file, sep=",", header=self.N_header_lines - 2) + if self.technique == "spectrum": + x_name, y_name = tuple(df.keys()) + x = df[x_name].to_numpy() + y = df[y_name].to_numpy() + cls = cls or Spectrum + return cls.from_data( + x, + y, + self.tstamp, + x_name, + y_name, + name=self.name, + technique=self.technique, + reader=self, + ) + + elif self.technique == "spectra": + names = {} + units = {} + swap_axes = False + for line in self.header_lines: + for line_start in ("values", "first row", "first column"): + if line.startswith(line_start): + t_x_or_y = re.search("([yxt])=", line).group(1) + names[t_x_or_y] = re.search(r"\'(.*)\'", line).group(1) + units[t_x_or_y] = re.search(r"\[(.*)\]", line).group(1) + if "row" in line_start and t_x_or_y == "t": # check! + swap_axes = True + z1 = np.array([float(key) for key in list(df.keys())[1:]]) + z1_and_y = df.to_numpy() + z0 = z1_and_y[:, 0] + y = z1_and_y[:, 1:] + if swap_axes: + t = z1 + x = z0 + y = y.swapaxes(0, 1) + else: + t = z0 + x = z1 + tseries = TimeSeries( + name=names["t"], unit_name=units["t"], data=t, tstamp=self.tstamp + ) + xseries = DataSeries(name=names["x"], unit_name=units["x"], data=x) + field = Field( + name=names["y"], + unit_name=units["y"], + data=y, + axes_series=[tseries, xseries], + ) + cls = cls or SpectrumSeries + return cls.from_field( + field, name=self.name, technique=self.technique, tstamp=self.tstamp + ) diff --git a/src/ixdat/spectra.py b/src/ixdat/spectra.py index 55db1794..d331aaa1 100644 --- a/src/ixdat/spectra.py +++ b/src/ixdat/spectra.py @@ -36,7 +36,7 @@ class Spectrum(Saveable): def __init__( self, name, - technique=None, + technique="spectrum", metadata=None, sample_name=None, reader=None, @@ -108,9 +108,7 @@ def data_objects(self): FIXME: with backend-specifying id's, field could check for itself whether its axes_series are already in the database. """ - series_list = self.field.axes_series - series_list.append(self.field) - return series_list + return self.series_list @classmethod def from_data( @@ -194,6 +192,10 @@ def xseries(self): """The x DataSeries is the first axis of the field""" return self.field.axes_series[0] + @property + def series_list(self): + return [self.field] + self.field.axes_series + @property def x(self): """The x data is the data attribute of the xseries""" @@ -253,6 +255,11 @@ def __add__(self, other): class SpectrumSeries(Spectrum): + def __init__(self, *args, **kwargs): + if not "technique" in kwargs: + kwargs["technique"] = "spectra" + super().__init__(*args, **kwargs) + @property def yseries(self): # Should this return an average or would that be counterintuitive? diff --git a/src/ixdat/techniques/spectroelectrochemistry.py b/src/ixdat/techniques/spectroelectrochemistry.py index 931025cd..bdaf26e6 100644 --- a/src/ixdat/techniques/spectroelectrochemistry.py +++ b/src/ixdat/techniques/spectroelectrochemistry.py @@ -14,6 +14,7 @@ def __init__(self, *args, **kwargs): self._reference_spectrum = None self.tracked_wavelengths = [] self.plot_waterfall = self._plotter.plot_waterfall + self.technique = "S-EC" @property def reference_spectrum(self): @@ -57,7 +58,9 @@ def spectra(self): def spectrum_series(self): """The SpectrumSeries that is the spectra of the SEC Measurement""" return SpectrumSeries.from_field( - self.spectra, tstamp=self.tstamp, name=self.name + " spectra" + self.spectra, + tstamp=self.tstamp, + name=self.name + " spectra", ) @property From 6fd836a735b850ea95bdc969ef03b05deb6d84b0 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Tue, 13 Jul 2021 23:13:39 +0100 Subject: [PATCH 072/118] sec track wavelengths --- .../test_msrh_sec_decay_reader.py | 17 +++-- .../reader_testers/test_msrh_sec_reader.py | 15 +++-- src/ixdat/plotters/sec_plotter.py | 67 ++++++++++++++++++- .../techniques/spectroelectrochemistry.py | 43 ++++++++++-- 4 files changed, 127 insertions(+), 15 deletions(-) diff --git a/development_scripts/reader_testers/test_msrh_sec_decay_reader.py b/development_scripts/reader_testers/test_msrh_sec_decay_reader.py index 8db85d19..b1c23e33 100644 --- a/development_scripts/reader_testers/test_msrh_sec_decay_reader.py +++ b/development_scripts/reader_testers/test_msrh_sec_decay_reader.py @@ -6,26 +6,35 @@ data_dir = Path.home() / "Dropbox/ixdat_resources/test_data/sec" sec_meas = Measurement.read( - data_dir / "decay/PDtest-1.35-1OSP-SP.csv", + # data_dir / "decay/PDtest-1.35-1OSP-SP.csv", + data_dir / "decay/PDtest-1.33-1OSP-SP.csv", path_to_ref_spec_file=data_dir / "WL.csv", - path_to_t_V_file=data_dir / "decay/PDtest-1.35-1OSP-E-t.csv", - path_to_t_J_file=data_dir / "decay/PDtest-1.35-1OSP-J-t.csv", + # path_to_t_V_file=data_dir / "decay/PDtest-1.35-1OSP-E-t.csv", + # path_to_t_J_file=data_dir / "decay/PDtest-1.35-1OSP-J-t.csv", + path_to_t_V_file=data_dir / "decay/PDtest-1.33-1OSP-E-t.csv", + path_to_t_J_file=data_dir / "decay/PDtest-1.33-1OSP-J-t.csv", tstamp=1, reader="msrh_sec_decay", ) +sec_meas.calibrate_RE(RE_vs_RHE=0.26) + sec_meas.set_reference_spectrum(t_ref=5) axes = sec_meas.plot_measurement( # V_ref=0.66, # can't do a V_ref for this as can't interpolate on potential.. # So OD will be calculated using the reference spectrum in WL.csv - cmap_name="jet", + # cmap_name="jet", + cmap_name="inferno", make_colorbar=False, ) # axes[0].get_figure().savefig("decay_vs_t.png") +axes = sec_meas.plot_wavelengths(wavelengths=["w500", "w600", "w700", "w800"]) + ax_w = sec_meas.plot_waterfall() +exit() # ax_w.get_figure().savefig("decay_waterfall.png") ref_spec = sec_meas.reference_spectrum diff --git a/development_scripts/reader_testers/test_msrh_sec_reader.py b/development_scripts/reader_testers/test_msrh_sec_reader.py index e45c2f0d..5cf72d3e 100644 --- a/development_scripts/reader_testers/test_msrh_sec_reader.py +++ b/development_scripts/reader_testers/test_msrh_sec_reader.py @@ -7,9 +7,7 @@ plt.close("all") - data_dir = Path.home() / "Dropbox/ixdat_resources/test_data/sec" - sec_meas = Measurement.read( data_dir / "test-7SEC.csv", path_to_ref_spec_file=data_dir / "WL.csv", @@ -20,12 +18,19 @@ ) sec_meas.calibrate_RE(RE_vs_RHE=0.26) # provide RE potential in [V] vs RHE -sec_meas.normalize_current(A_el=2) # provide electrode area in [cm^2] +sec_meas.normalize_current(A_el=1) # provide electrode area in [cm^2] export_name = "exported_sec.csv" sec_meas.export(export_name) -ixdat_sec = Measurement.read(export_name, reader="ixdat") -ixdat_sec.plot_measurement(V_ref=0.4, cmap_name="jet") + +ixdat_sec = Measurement.read("exported_sec.csv", reader="ixdat") + +ixdat_sec.plot_measurement(V_ref=0.4, cmap_name="inferno") +axes = ixdat_sec.plot_wavelengths(wavelengths=["w500", "w600", "w700", "w800"]) +axes = ixdat_sec.plot_wavelengths_vs_potential( + wavelengths=["w500", "w600", "w700", "w800"] +) + axes = sec_meas.plot_measurement( V_ref=0.4, diff --git a/src/ixdat/plotters/sec_plotter.py b/src/ixdat/plotters/sec_plotter.py index 0ad2bd3c..6e57ca40 100644 --- a/src/ixdat/plotters/sec_plotter.py +++ b/src/ixdat/plotters/sec_plotter.py @@ -1,9 +1,9 @@ -import numpy as np import matplotlib as mpl -from matplotlib import pyplot as plt + from .base_mpl_plotter import MPLPlotter from .ec_plotter import ECPlotter from .spectrum_plotter import SpectrumSeriesPlotter +from ..exceptions import SeriesNotFoundError class SECPlotter(MPLPlotter): @@ -202,3 +202,66 @@ def plot_vs_potential( ) axes[1].set_xlim(axes[0].get_xlim()) return axes + + def plot_wavelengths( + self, + measurement=None, + wavelengths=None, + axes=None, + cmap_name="jet", + tspan=None, + **kwargs, + ): + measurement = measurement or self.measurement + wavelengths = wavelengths or measurement.tracked_wavelengths + + cmap = mpl.cm.get_cmap(cmap_name) + norm = mpl.colors.Normalize(vmin=min(measurement.wl), vmax=max(measurement.wl)) + + if not axes: + axes = self.new_two_panel_axes(n_bottom=2) + for wl_str in wavelengths: + x = float(wl_str[1:]) + try: + t, y = measurement.grab(wl_str, tspan=tspan) + except SeriesNotFoundError: + measurement.track_wavelength(x) + t, y = measurement.grab(wl_str, tspan=tspan) + axes[0].plot(t, y, color=cmap(norm(x)), label=wl_str) + axes[0].legend() + + self.ec_plotter.plot_measurement( + measurement=measurement, axes=axes[1:], tspan=tspan, **kwargs + ) + + def plot_wavelengths_vs_potential( + self, + measurement=None, + wavelengths=None, + axes=None, + cmap_name="jet", + tspan=None, + **kwargs, + ): + measurement = measurement or self.measurement + wavelengths = wavelengths or measurement.tracked_wavelengths + + cmap = mpl.cm.get_cmap(cmap_name) + norm = mpl.colors.Normalize(vmin=min(measurement.wl), vmax=max(measurement.wl)) + + if not axes: + axes = self.new_two_panel_axes() + for wl_str in wavelengths: + x = float(wl_str[1:]) + try: + t, y = measurement.grab(wl_str, tspan=tspan) + except SeriesNotFoundError: + measurement.track_wavelength(x) + t, y = measurement.grab(wl_str, tspan=tspan) + v = measurement.v + axes[0].plot(v, y, color=cmap(norm(x)), label=wl_str) + axes[0].legend() + + self.ec_plotter.plot_vs_potential( + measurement=measurement, ax=axes[1], tspan=tspan, **kwargs + ) diff --git a/src/ixdat/techniques/spectroelectrochemistry.py b/src/ixdat/techniques/spectroelectrochemistry.py index bdaf26e6..57e40be6 100644 --- a/src/ixdat/techniques/spectroelectrochemistry.py +++ b/src/ixdat/techniques/spectroelectrochemistry.py @@ -1,6 +1,6 @@ from .ec import ECMeasurement from ..spectra import Spectrum -from ..data_series import Field +from ..data_series import Field, ValueSeries import numpy as np from scipy.interpolate import interp1d from ..spectra import SpectrumSeries @@ -13,7 +13,9 @@ def __init__(self, *args, **kwargs): ECMeasurement.__init__(self, *args, **kwargs) self._reference_spectrum = None self.tracked_wavelengths = [] - self.plot_waterfall = self._plotter.plot_waterfall + self.plot_waterfall = self.plotter.plot_waterfall + self.plot_wavelengths = self.plotter.plot_wavelengths + self.plot_wavelengths_vs_potential = self.plotter.plot_wavelengths_vs_potential self.technique = "S-EC" @property @@ -130,9 +132,9 @@ def get_spectrum(self, V=None, t=None, index=None): Return Spectrum: The spectrum. The data is (spectrum.x, spectrum.y) """ - if V and V in self.v: # woohoo, can skip interolation! + if V and V in self.v: # woohoo, can skip interpolation! index = int(np.argmax(self.v == V)) - elif t and t in self.t: # woohoo, can skip interolation! + elif t and t in self.t: # woohoo, can skip interpolation! index = int(np.argmax(self.t == t)) if index: # then we're done: return self.spectrum_series[index] @@ -198,3 +200,36 @@ def get_dOD_spectrum( axes_series=[self.wavelength], ) return Spectrum.from_field(field) + + def track_wavelength(self, wl, width=10, V_ref=None, t_ref=None, index_ref=None): + if V_ref or t_ref or index_ref: + spectrum_ref = self.get_spectrum(V=V_ref, t=t_ref, index=index_ref) + else: + spectrum_ref = self.reference_spectrum + x = self.wl + if width: # averaging + wl_mask = np.logical_and(wl - width / 2 < x, x < wl + width / 2) + counts_ref = np.mean(spectrum_ref.y[wl_mask]) + counts_wl = np.mean(self.spectra.data[:, wl_mask], axis=1) + else: # interpolation + counts_ref = np.interp(wl, spectrum_ref.x, spectrum_ref.y) + counts_wl = [] + for counts_i in self.spectra.data: + c = np.interp(wl, x, counts_i) + counts_wl.append(c) + counts_wl = np.array(counts_wl) + dOD_wl = -np.log10(counts_wl / counts_ref) + raw_name = f"w{int(wl)} raw" + dOD_name = f"w{int(wl)}" + tseries = self.spectra.axes_series[0] + raw_vseries = ValueSeries( + name=raw_name, unit_name="counts", data=counts_wl, tseries=tseries + ) + dOD_vseries = ValueSeries( + name=dOD_name, unit_name="", data=dOD_wl, tseries=tseries + ) + self[raw_name] = raw_vseries + self[dOD_name] = dOD_vseries + self.tracked_wavelengths.append(dOD_name) + + return dOD_vseries From 0594f0ff1791d00fd21202a779643797fd4764a4 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Fri, 16 Jul 2021 17:48:38 +0100 Subject: [PATCH 073/118] finish docstrings for sec --- .../reader_testers/test_msrh_sec_reader.py | 5 ++- src/ixdat/exporters/sec_exporter.py | 9 ++++- src/ixdat/exporters/spectrum_exporter.py | 38 +++++++++++++++++++ src/ixdat/readers/ixdat_csv.py | 31 ++++++++++++++- src/ixdat/spectra.py | 5 +++ .../techniques/spectroelectrochemistry.py | 24 +++++++++++- 6 files changed, 107 insertions(+), 5 deletions(-) diff --git a/development_scripts/reader_testers/test_msrh_sec_reader.py b/development_scripts/reader_testers/test_msrh_sec_reader.py index 5cf72d3e..3e95450d 100644 --- a/development_scripts/reader_testers/test_msrh_sec_reader.py +++ b/development_scripts/reader_testers/test_msrh_sec_reader.py @@ -25,7 +25,9 @@ ixdat_sec = Measurement.read("exported_sec.csv", reader="ixdat") -ixdat_sec.plot_measurement(V_ref=0.4, cmap_name="inferno") +axes = ixdat_sec.plot_measurement(V_ref=0.4, cmap_name="inferno") +axes[0].get_figure().savefig("sec_plot.png") + axes = ixdat_sec.plot_wavelengths(wavelengths=["w500", "w600", "w700", "w800"]) axes = ixdat_sec.plot_wavelengths_vs_potential( wavelengths=["w500", "w600", "w700", "w800"] @@ -43,6 +45,7 @@ cmap_name="jet", make_colorbar=True, ) +ax.get_figure().savefig("sec_waterfall.png") axes2 = sec_meas.plot_vs_potential(V_ref=0.66, cmap_name="jet", make_colorbar=False) axes2 = sec_meas.plot_vs_potential( diff --git a/src/ixdat/exporters/sec_exporter.py b/src/ixdat/exporters/sec_exporter.py index 3729fb99..8e0dd979 100644 --- a/src/ixdat/exporters/sec_exporter.py +++ b/src/ixdat/exporters/sec_exporter.py @@ -21,7 +21,14 @@ def default_v_list(self): return v_list def prepare_header_and_data(self, measurement, v_list, tspan): - """Add lines pointing to the 'spectra' and 'reference' spectrum""" + """Do the standard ixdat csv export header preparation, plus SEC stuff. + + The SEC stuff is: + - export the spectroelectrochemistry spectra + - export the actual reference spectrum + - add lines to the main file header pointing to the files with the + above two exports. + """ super().prepare_header_and_data(measurement, v_list, tspan) path_to_spectra_file = self.path_to_file.parent / ( self.path_to_file.stem + "_spectra.csv" diff --git a/src/ixdat/exporters/spectrum_exporter.py b/src/ixdat/exporters/spectrum_exporter.py index c786601a..df996f67 100644 --- a/src/ixdat/exporters/spectrum_exporter.py +++ b/src/ixdat/exporters/spectrum_exporter.py @@ -3,11 +3,28 @@ class SpectrumExporter: + """An ixdat CSV exporter for spectra. Uses pandas.""" + def __init__(self, spectrum, delim=","): + """Initiate the SpectrumExporter. + + Args: + spectrum (Spectrum): The spectrum to export by default + delim (char): The separator for the .csv file. Note that this cannot be + the ",\t" used by ixdat's main exporter since pandas only accepts single + character delimiters. + """ self.spectrum = spectrum self.delim = delim def export(self, spectrum, path_to_file): + """Export spectrum to path_to_file. + + Args: + spectrum (Spectrum): The spectrum to export if different from self.spectrum + path_to_file (str or Path): The path of the file to export to. Note that if a + file already exists with this path, it will be overwritten. + """ spectrum = spectrum or self.spectrum df = pd.DataFrame({spectrum.x_name: spectrum.x, spectrum.y_name: spectrum.y}) @@ -31,11 +48,32 @@ def export(self, spectrum, path_to_file): class SpectrumSeriesExporter: + """An exporter for ixdat spectrum series.""" + def __init__(self, spectrum_series, delim=","): + """Initiate the SpectrumSeriesExporter. + + Args: + spectrum_series (SpectrumSeries): The spectrum to export by default + delim (char): The separator for the .csv file. Note that this cannot be + the ",\t" used by ixdat's main exporter since pandas only accepts single + character delimiters. + """ self.spectrum_series = spectrum_series self.delim = delim def export(self, spectrum_series=None, path_to_file=None, spectra_as_rows=True): + """Export spectrum to path_to_file. + + spectrum (Spectrum): The spectrum to export if different from self.spectrum + path_to_file (str or Path): The path of the file to export to. Note that if a + file already exists with this path, it will be overwritten. + spectra_as_rows (bool): This specifies the orientation of the data exported. + If True, the scanning variabe (e.g. wavelength) increases to the right and + the time variable increases downward. If False, the scanning variable + increases downwards and the time variable increases to the right. Either + way it is clarified in the file header. Defaults to True. + """ spectrum_series = spectrum_series or self.spectrum_series diff --git a/src/ixdat/readers/ixdat_csv.py b/src/ixdat/readers/ixdat_csv.py index 086d897f..04d9c60e 100644 --- a/src/ixdat/readers/ixdat_csv.py +++ b/src/ixdat/readers/ixdat_csv.py @@ -89,7 +89,13 @@ def read(self, path_to_file, name=None, cls=None, **kwargs): Args: path_to_file (Path): The full abs or rel path including the ".mpt" extension + name (str): The name of the measurement to return (defaults to path_to_file) + cls (Measurement subclass): The class of measurement to return. By default, + cls will be determined from the technique specified in the header of + path_to_file. **kwargs (dict): Key-word arguments are passed to ECMeasurement.__init__ + + Returns cls: a Measurement of type cls """ path_to_file = Path(path_to_file) if path_to_file else self.path_to_file if self.file_has_been_read: @@ -233,6 +239,7 @@ def process_data_line(self, line): self.column_data[name].append(value) def read_aux_file(self, path_to_aux_file, name): + """Read an auxiliary file and include its series list in the measurement""" spec = IxdatSpectrumReader().read(path_to_aux_file, name=name) self.aux_series_list += spec.series_list @@ -253,7 +260,24 @@ def get_column_unit(column_name): class IxdatSpectrumReader(IxdatCSVReader): + """A reader for ixdat spectra.""" + def read(self, path_to_file, name=None, cls=None, **kwargs): + """Read an ixdat spectrum. + + This reads the header with the process_line() function inherited from + IxdatCSVReader. Then it uses pandas to read the data. + + Args: + path_to_file (Path): The full abs or rel path including the ".mpt" extension + name (str): The name of the measurement to return (defaults to path_to_file) + cls (Spectrum subclass): The class of measurement to return. By default, + cls will be determined from the technique specified in the header of + path_to_file. + **kwargs (dict): Key-word arguments are passed to ECMeasurement.__init__ + + Returns cls: a Spectrum of type cls + """ with open(path_to_file, "r") as f: for line in f: if self.place_in_file == "header": @@ -263,11 +287,12 @@ def read(self, path_to_file, name=None, cls=None, **kwargs): df = pd.read_csv(path_to_file, sep=",", header=self.N_header_lines - 2) if self.technique == "spectrum": + # FIXME: in the future, this needs to cover all spectrum classes x_name, y_name = tuple(df.keys()) x = df[x_name].to_numpy() y = df[y_name].to_numpy() cls = cls or Spectrum - return cls.from_data( + return cls.from_data( # see Spectrum.from_data() x, y, self.tstamp, @@ -279,6 +304,7 @@ def read(self, path_to_file, name=None, cls=None, **kwargs): ) elif self.technique == "spectra": + # FIXME: in the future, this needs to cover all spectrum series classes names = {} units = {} swap_axes = False @@ -295,6 +321,7 @@ def read(self, path_to_file, name=None, cls=None, **kwargs): z0 = z1_and_y[:, 0] y = z1_and_y[:, 1:] if swap_axes: + # This is the case if the file was export with spectra_as_rows = False. t = z1 x = z0 y = y.swapaxes(0, 1) @@ -312,6 +339,6 @@ def read(self, path_to_file, name=None, cls=None, **kwargs): axes_series=[tseries, xseries], ) cls = cls or SpectrumSeries - return cls.from_field( + return cls.from_field( # see SpectrumSeries.from_field() field, name=self.name, technique=self.technique, tstamp=self.tstamp ) diff --git a/src/ixdat/spectra.py b/src/ixdat/spectra.py index d331aaa1..19a7c2a6 100644 --- a/src/ixdat/spectra.py +++ b/src/ixdat/spectra.py @@ -194,6 +194,7 @@ def xseries(self): @property def series_list(self): + """A Spectrum's series list includes its field and its axes_series.""" return [self.field] + self.field.axes_series @property @@ -281,6 +282,7 @@ def t(self): @property def t_name(self): + """The name of the time variable of the spectrum series""" return self.tseries.name @property @@ -290,10 +292,12 @@ def xseries(self): @property def x(self): + """The x (scanning variable) data""" return self.xseries.data @property def x_name(self): + """The name of the scanning variable""" return self.xseries.name def __getitem__(self, key): @@ -313,6 +317,7 @@ def __getitem__(self, key): @property def y_average(self): + """The y-data of the average spectrum""" return np.mean(self.y, axis=0) @property diff --git a/src/ixdat/techniques/spectroelectrochemistry.py b/src/ixdat/techniques/spectroelectrochemistry.py index 57e40be6..d54bcb5b 100644 --- a/src/ixdat/techniques/spectroelectrochemistry.py +++ b/src/ixdat/techniques/spectroelectrochemistry.py @@ -202,6 +202,26 @@ def get_dOD_spectrum( return Spectrum.from_field(field) def track_wavelength(self, wl, width=10, V_ref=None, t_ref=None, index_ref=None): + """Return and cache a ValueSeries for the dOD for a specific wavelength. + + The cacheing adds wl_str to the SECMeasurement's data series, where + wl_str = "w" + int(wl) + This is dOD. The raw is also added as wl_str + "_raw". + So, to get the raw counts for a specific wavelength, call this function as + and then use __getitem__, as in: sec_meas[wl_str + "_raw"] + If V_ref, t_ref, or index_ref are provided, they specify what to reference dOD + to. Otherwise, dOD is referenced to the SECMeasurement's reference_spectrum. + + Args: + wl (float): The wavelength to track in [nm] + width (float): The width around wl to average. For example, if wl=400 and + width = 20, the spectra will be averaged between 390 and 410 nm to get + the values. Defaults to 10 + V_ref (float): The potential at which to get the reference spectrum + t_ref (float): The time at which to get the reference spectrum + index_ref (int): The index of the reference spectrum + Returns ValueSeries: The dOD value of the spectrum at wl. + """ if V_ref or t_ref or index_ref: spectrum_ref = self.get_spectrum(V=V_ref, t=t_ref, index=index_ref) else: @@ -229,7 +249,9 @@ def track_wavelength(self, wl, width=10, V_ref=None, t_ref=None, index_ref=None) name=dOD_name, unit_name="", data=dOD_wl, tseries=tseries ) self[raw_name] = raw_vseries + # FIXME: better caching. See https://github.com/ixdat/ixdat/pull/11 self[dOD_name] = dOD_vseries - self.tracked_wavelengths.append(dOD_name) + # FIXME: better caching. See https://github.com/ixdat/ixdat/pull/11 + self.tracked_wavelengths.append(dOD_name) # For the exporter. return dOD_vseries From f03ed5763e7b77911be5251ff549056fab57acf0 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Fri, 16 Jul 2021 20:05:20 +0100 Subject: [PATCH 074/118] spectra and SEC documentation --- docs/source/exporter_docs/index.rst | 15 + docs/source/figures/sec_class.svg | 554 +++++++++++++++++++++++ docs/source/plotter_docs/index.rst | 20 + docs/source/reader_docs/index.rst | 18 +- docs/source/technique_docs/index.rst | 2 + docs/source/technique_docs/mass_spec.rst | 2 +- docs/source/technique_docs/sec.rst | 41 +- docs/source/technique_docs/spectra.rst | 28 ++ docs/source/tutorials.rst | 16 +- 9 files changed, 688 insertions(+), 8 deletions(-) create mode 100644 docs/source/figures/sec_class.svg create mode 100644 docs/source/technique_docs/spectra.rst diff --git a/docs/source/exporter_docs/index.rst b/docs/source/exporter_docs/index.rst index 45015bff..47f9f051 100644 --- a/docs/source/exporter_docs/index.rst +++ b/docs/source/exporter_docs/index.rst @@ -10,6 +10,7 @@ https://github.com/ixdat/tutorials/blob/main/loading_appending_and_saving/co_str The ``csv_exporter`` module ........................... +.. _csv-exporter: .. automodule:: ixdat.exporters.csv_exporter :members: @@ -26,5 +27,19 @@ The ``ecms_exporter`` module .. automodule:: ixdat.exporters.ecms_exporter :members: +The ``spectrum_exporter`` module +........................... +.. _spectrum-exporter: + +.. automodule:: ixdat.exporters.spectrum_exporter + :members: + +The ``sec_exporter`` module +............................ +.. _sec-exporter: + +.. automodule:: ixdat.exporters.sec_exporter + :members: + diff --git a/docs/source/figures/sec_class.svg b/docs/source/figures/sec_class.svg new file mode 100644 index 00000000..89dab2db --- /dev/null +++ b/docs/source/figures/sec_class.svg @@ -0,0 +1,554 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + Measurement base-Plugging withBackend, etc.-Relationships withDataSeries-Appending/hyphenating withthe `+` operator + + ECMeasurementEverything inMeasurementbase, AND:-Current and potential-Calibrating RE, normalizing,correcting ohmic drop-Selecting CV cycles-Integrating over potential range + + SpectrumSeriesEverything inSpectrumbase, AND:-Evolution over time-Heat plots-Waterfall plots + + SpectroECMeasurementEverything inECMeasurement, AND:-SpectrumSeriesfor spec data-dODcalculation + + + + + DataSeries-Raw data-Unit-Keeping track of time + + + Spectrum base-Relationship with Backend,etc-xand yDataSeries-Plotting x vs y, selecting ranges in x-Simple peak fitting (gauss) + + + SpectroelectrochemistryRelations and inheritance + diff --git a/docs/source/plotter_docs/index.rst b/docs/source/plotter_docs/index.rst index 64964063..9f31107e 100644 --- a/docs/source/plotter_docs/index.rst +++ b/docs/source/plotter_docs/index.rst @@ -52,4 +52,24 @@ The ``ecms_plotter`` module .. automodule:: ixdat.plotters.ecms_plotter :members: +Spectra +------- +The ``spectrum_plotter`` module +............................... + +.. _spectrum-plotter: + +.. automodule:: ixdat.plotters.spectrum_plotter + :members: + +Spectroelectrochemistry +----------------------- + +.. _sec-plotter: + +The ``sec_plotter`` module +.......................... + +.. automodule:: ixdat.plotters.sec_plotter + :members: \ No newline at end of file diff --git a/docs/source/reader_docs/index.rst b/docs/source/reader_docs/index.rst index 3b5a2f56..150c28e2 100644 --- a/docs/source/reader_docs/index.rst +++ b/docs/source/reader_docs/index.rst @@ -41,6 +41,8 @@ The ``cinfdata`` module Electrochemistry and sub-techniques ------------------------------------ +These are readers which by default return an ``ECMeasurement``. +(See :ref:`electrochemistry`) The ``biologic`` module ........................ @@ -55,13 +57,15 @@ The ``autolab`` module :members: The ``ivium`` module -........................ +.................... .. automodule:: ixdat.readers.ivium :members: Mass Spectrometry and sub-techniques ------------------------------------ +These are readers which by default return an ``MSMeasurement``. +(See :ref:`mass-spec`) The ``pfeiffer`` module ........................ @@ -71,6 +75,8 @@ The ``pfeiffer`` module EC-MS and sub-techniques ------------------------ +These are readers which by default return an ``ECMSMeasurement``. +(See :ref:`ec-ms`) The ``zilien`` module ........................ @@ -85,3 +91,13 @@ The ``ec_ms_pkl`` module :members: +EC-MS and sub-techniques +------------------------ +These are readers which by default return a ``SpectroECMeasurement``. +(See :ref:`s-ec`) + +The ``msrh_sec`` module +....................... + +.. automodule:: ixdat.readers.msrh_sec + :members: \ No newline at end of file diff --git a/docs/source/technique_docs/index.rst b/docs/source/technique_docs/index.rst index 751f3034..2fd5143a 100644 --- a/docs/source/technique_docs/index.rst +++ b/docs/source/technique_docs/index.rst @@ -17,6 +17,7 @@ A full list of the techniques and there names is in the ``TECHNIQUE_CLASSES`` di 'CV': , 'MS': , 'EC-MS': + 'S-EC': } .. toctree:: @@ -26,3 +27,4 @@ A full list of the techniques and there names is in the ``TECHNIQUE_CLASSES`` di mass_spec ec_ms sec + spectra diff --git a/docs/source/technique_docs/mass_spec.rst b/docs/source/technique_docs/mass_spec.rst index ace32a9d..b96829a7 100644 --- a/docs/source/technique_docs/mass_spec.rst +++ b/docs/source/technique_docs/mass_spec.rst @@ -14,7 +14,7 @@ The main TechniqueMeasurement class for MID data is the :ref:`MSMeasurement`. Classes dealing with spectra are under development. The ``ms`` module -........................ +................. .. automodule:: ixdat.techniques.ms :members: \ No newline at end of file diff --git a/docs/source/technique_docs/sec.rst b/docs/source/technique_docs/sec.rst index 792a37d7..7cd7d0cd 100644 --- a/docs/source/technique_docs/sec.rst +++ b/docs/source/technique_docs/sec.rst @@ -3,9 +3,40 @@ Spectro-Electrochemistry ======================== -Spectro-Electrochemsitry (SEC) is under development and not yet released. To use it, you -will need to run from the `**[spectroelectrochemistry]** `_ -branch of the repository. See :ref:`developing`. +Spectro-Electrochemsitry (S-EC) can refer to (i) a broad range of in-situ techniques +hyphenating electrochemistry to some kind of spectrometry or (ii) more specifically, +the combination of electrochemistry and visible-light spectroscopy. In ``ixdat``, we +use the latter term. -An example of using ixdat to plot SEC data is -`here `_. +S-EC data is organized in a ``SpectroECMeasurement``, which inherits from ``ECMeasurement`` +(see :ref:`electrochemistry`) and uses a ``SpectrumSeries`` (see :ref:`Spectra `) +for managing the 2-D data array formed by the sequential spectra (see Figure). +To this, the class adds delta optical density (``dOD``) calculations. Methods +such as ``calc_dOD``, ``get_dOD_spectrum``, and ``track_wavelength`` take as +an optional argument a specification of the time/potential/spectrum index to +use as the reference for optical density calculation. If not provided, the +object's ``reference_spectrum`` is used, which itself can be set by the +``set_reference_spectrum`` method. + +.. figure:: ../figures/sec_class.svg + :width: 600 + +The data structure is the same whether the experiment is done as a slow potential scan with +adsorption vs potential in mind, or as a potential jump or release with time-resolved +behavior in mind. + +Plots of S-EC are made by the :ref:`SECPlotter `. These are either heat plots +(``plot_measurement`` and ``plot_vs_potential``) or coplotted cross-sections (``plot_waterfall`` +and ``plot_wavelengths``). :ref:`Exporting SEC data ` results in a master file with +the EC data and any tracked wavelengths and two auxiliary files with (i) the +spectrum series and (ii) the reference spectrum. + +A :ref:`jupyter notebook tutorial ` for S-EC is available. + +Fitting of spectroelectrochemistry data is not yet supported in ``ixdat``. + +The ``spectroelectrochemistry`` module +...................................... + +.. automodule:: ixdat.techniques.spectroelectrochemistry + :members: \ No newline at end of file diff --git a/docs/source/technique_docs/spectra.rst b/docs/source/technique_docs/spectra.rst new file mode 100644 index 00000000..4c789234 --- /dev/null +++ b/docs/source/technique_docs/spectra.rst @@ -0,0 +1,28 @@ +.. _spectra: + +Spectrum +======== + +The position of spectra is not yet completely set in ixdat. + +A spectrum is in essence just a 1-D field where a response variable (e.g. counts, +detector current, adsorption) lives on a space defined by a scanning variable (e.g. wavelength, +mass-to-charge ratio, two-theta). +As such it could be a DataSeries on par with ValueSeries (a 1-D field with a value living in +a space defined by a TimeSeries). +A Spectrum is however a stand-alone output of an experimental technique. It could also be a type of Measurement. + +As it is now, ``Spectrum`` is its own base class on par with ``Measurement``, with its own table (i.e. the ``Spectrum`` class +inherits directly from ``Saveable``). It has properties which give quick access to the scanning +variable and response as ``x`` and ``y``, respectively. It also has its +own :ref:`plotter ` and :ref:`exporter `. + +Similar questions can be raised about a sequence of spectra - whether it is a Measurement or a 2-D field. +As it is now, sequences of spectra are represented by ``SpectrumSeries``, which inherits from +``Spectrum``. + +The ``spectra`` module +...................... + +.. automodule:: ixdat.spectra + :members: \ No newline at end of file diff --git a/docs/source/tutorials.rst b/docs/source/tutorials.rst index a8c6ba9d..7469a798 100644 --- a/docs/source/tutorials.rst +++ b/docs/source/tutorials.rst @@ -1,3 +1,5 @@ +.. _tutorials: + ========= Tutorials ========= @@ -15,7 +17,6 @@ both based on electrochemistry data: Loading, selecting, calibrating, and exporting data *************************************************** - Location: `loading_appending_and_saving/export_demo_data_as_csv.ipynb `_ This tutorial shows with electrochemistry data how to load, append, and export data. @@ -42,6 +43,19 @@ It reads ixdat-exported data directly from github. A worked example based on the methods in this tutorial +Spectroelectrochemistry +*********************** + +.. _sec-tutorial: + +Location:`spectroelectrochemistry/ `_ + +This tutorial demonstrates importing, plotting, and exporting spectroelectrochemistry (S-EC) data +It shows delta optical density calculation and both calculation and plotting of the full 2-D data field and +cross sections (i.e. spectra and wavelength-vs-time). + +The example data is not yet publically available. + Article repositories -------------------- From 6fc3bbbddff0ceff8270431c43ba1ca21c3ae0cb Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Sat, 17 Jul 2021 13:13:17 +0100 Subject: [PATCH 075/118] sphinx instructions in tools.rst, ready for PR --- TOOLS.rst | 26 +++++++++++++++++++++++++- docs/source/technique_docs/sec.rst | 6 ++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/TOOLS.rst b/TOOLS.rst index a6947779..8f1ad712 100644 --- a/TOOLS.rst +++ b/TOOLS.rst @@ -11,7 +11,7 @@ development: * **sphinx** is used to build documentation The following is a list of "tool and commands runners": - + * **invoke** is used during development, to run tools and other pre-configured maintenance tasks inside the existing development environment @@ -41,6 +41,8 @@ summary here with our suggestions for usage. Tools ----- +black +..... **black** is an autoformatter, which fixes your white space usage etc. https://black.readthedocs.io/en/stable/ It's nice to run it from the same terminal @@ -49,6 +51,8 @@ before committing (and avoid a "fix formatting" commit later). To get black and the other tools available in git bash, you have to tell ~\.bashrc where it is (see Windows instructions below under git hooks). +flake8 +...... **flake8** is a linter, which checks the code for errors. https://flake8.pycqa.org/en/latest/ This includes @@ -64,9 +68,29 @@ Additional allowances may need to be added there. flake8 also enforces a maximum line length of 89, chosen by Soren to match the default setting of black (+/- 1 char). +pytest +...... **pytest** is a suite of stuff used to write and run software tests. https://docs.pytest.org/en/stable/ +sphinx +...... +**sphinx** is used to generate the beautiful documentation on +https://ixdat.readthedocs.io from ReStructuredTex and ixdat source code. +To set it up just install sphinx, if you haven't already. In your terminal or Anaconda prompt, type:: + + $ pip install sphinx + +Then, to build the documentation, navigate to ``ixdat/docs``, and run in your terminal or Anaconda prompt:: + + $ ./make html + +Note, if you get an "access denied" error, you will just need to run the terminal as an administrator. + +Then you can see the built documentation in your browser by double-clicking +``ixdat/docs/build/html/index.html`` + + Tool runners ------------ diff --git a/docs/source/technique_docs/sec.rst b/docs/source/technique_docs/sec.rst index 7cd7d0cd..bbb03c6b 100644 --- a/docs/source/technique_docs/sec.rst +++ b/docs/source/technique_docs/sec.rst @@ -4,9 +4,11 @@ Spectro-Electrochemistry ======================== Spectro-Electrochemsitry (S-EC) can refer to (i) a broad range of in-situ techniques -hyphenating electrochemistry to some kind of spectrometry or (ii) more specifically, +hyphenating electrochemistry to some kind of spectrometry (see e.g. +`Lozeman et al, 2020 `_ +) or (ii) more specifically, the combination of electrochemistry and visible-light spectroscopy. In ``ixdat``, we -use the latter term. +use the latter meaning. S-EC data is organized in a ``SpectroECMeasurement``, which inherits from ``ECMeasurement`` (see :ref:`electrochemistry`) and uses a ``SpectrumSeries`` (see :ref:`Spectra `) From e4c1c9dd6cb0516550477d4de941e362b3980c68 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Mon, 8 Nov 2021 23:09:18 +0000 Subject: [PATCH 076/118] implement Caiwu's review of spectroelectrochemistry --- .../reader_testers/test_msrh_sec_reader.py | 17 +++++------ docs/source/conf.py | 2 +- src/ixdat/plotters/sec_plotter.py | 28 +++++++++++++++++++ src/ixdat/readers/msrh_sec.py | 8 +++--- src/ixdat/spectra.py | 4 ++- .../techniques/spectroelectrochemistry.py | 16 +++++++---- 6 files changed, 55 insertions(+), 20 deletions(-) diff --git a/development_scripts/reader_testers/test_msrh_sec_reader.py b/development_scripts/reader_testers/test_msrh_sec_reader.py index 3e95450d..f5727ccc 100644 --- a/development_scripts/reader_testers/test_msrh_sec_reader.py +++ b/development_scripts/reader_testers/test_msrh_sec_reader.py @@ -20,19 +20,20 @@ sec_meas.calibrate_RE(RE_vs_RHE=0.26) # provide RE potential in [V] vs RHE sec_meas.normalize_current(A_el=1) # provide electrode area in [cm^2] +sec_meas.set_reference_spectrum(V_ref=0.66) +ax = sec_meas.get_dOD_spectrum(V=1.0, V_ref=0.66).plot(color="b", label="species 1") +sec_meas.get_dOD_spectrum(V=1.4, V_ref=1.0).plot(color="g", label="species 2", ax=ax) +sec_meas.get_dOD_spectrum(V=1.7, V_ref=1.4).plot(color="r", label="species 3", ax=ax) +ax.legend() + export_name = "exported_sec.csv" sec_meas.export(export_name) -ixdat_sec = Measurement.read("exported_sec.csv", reader="ixdat") +sec_reloaded = Measurement.read(export_name, reader="ixdat") -axes = ixdat_sec.plot_measurement(V_ref=0.4, cmap_name="inferno") -axes[0].get_figure().savefig("sec_plot.png") - -axes = ixdat_sec.plot_wavelengths(wavelengths=["w500", "w600", "w700", "w800"]) -axes = ixdat_sec.plot_wavelengths_vs_potential( - wavelengths=["w500", "w600", "w700", "w800"] -) +sec_reloaded.set_reference_spectrum(V_ref=0.66) +sec_reloaded.plot_vs_potential(cmap_name="jet") axes = sec_meas.plot_measurement( V_ref=0.4, diff --git a/docs/source/conf.py b/docs/source/conf.py index 65754179..b44637a3 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -64,7 +64,7 @@ # a list of builtin themes. # html_theme = "sphinx_rtd_theme" -html_logo = 'figures/logo.svg' +html_logo = "figures/logo.svg" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/src/ixdat/plotters/sec_plotter.py b/src/ixdat/plotters/sec_plotter.py index 6e57ca40..c4c69a2d 100644 --- a/src/ixdat/plotters/sec_plotter.py +++ b/src/ixdat/plotters/sec_plotter.py @@ -212,6 +212,19 @@ def plot_wavelengths( tspan=None, **kwargs, ): + """Plot the dO.D. for specific wavelength in the top panel and EC in bottom + + Args: + measurement (Measurement): The measurement to be plotted, if different from + self.measurement + wavelengths (list of str): The names of the wavelengths to track as strings, + e.g. "w400" for 400 nm + axes (list of Ax): The axes to plot on, defaults to new matplotlib axes + cmap_name (str): Name of the colormap. Defaults to "jet" + tspan (timespan): The timespan to plot + **kwargs: Additional key-word arguments are passed on to + ECPlotter.plot_measurement + """ measurement = measurement or self.measurement wavelengths = wavelengths or measurement.tracked_wavelengths @@ -229,6 +242,7 @@ def plot_wavelengths( t, y = measurement.grab(wl_str, tspan=tspan) axes[0].plot(t, y, color=cmap(norm(x)), label=wl_str) axes[0].legend() + axes[0].set_ylabel("$\Delta$O.D.") self.ec_plotter.plot_measurement( measurement=measurement, axes=axes[1:], tspan=tspan, **kwargs @@ -243,6 +257,19 @@ def plot_wavelengths_vs_potential( tspan=None, **kwargs, ): + """Plot the dO.D. for specific wavelength in the top panel vs potential + + Args: + measurement (Measurement): The measurement to be plotted, if different from + self.measurement + wavelengths (list of str): The names of the wavelengths to track as strings, + e.g. "w400" for 400 nm + axes (list of Ax): The axes to plot on, defaults to new matplotlib axes + cmap_name (str): Name of the colormap. Defaults to "jet" + tspan (timespan): The timespan to plot + **kwargs: Additional key-word arguments are passed on to + ECPlotter.plot_vs_potential + """ measurement = measurement or self.measurement wavelengths = wavelengths or measurement.tracked_wavelengths @@ -261,6 +288,7 @@ def plot_wavelengths_vs_potential( v = measurement.v axes[0].plot(v, y, color=cmap(norm(x)), label=wl_str) axes[0].legend() + axes[0].set_ylabel("$\Delta$O.D.") self.ec_plotter.plot_vs_potential( measurement=measurement, ax=axes[1], tspan=tspan, **kwargs diff --git a/src/ixdat/readers/msrh_sec.py b/src/ixdat/readers/msrh_sec.py index be89a749..33cdb7bd 100644 --- a/src/ixdat/readers/msrh_sec.py +++ b/src/ixdat/readers/msrh_sec.py @@ -30,7 +30,7 @@ def read( path_to_ref_spec_file (Path or str): The full path to the file containing the wavelenth data, together usually with the adsorption-free spectrum. The length of the columns should be the same as in the spectrum data - but in practice is a few points longer. The excess points at the ends + but in practice is a few points longer. The excess points at the starts of the columns are discarded. path_to_V_J_file (Path or str): The full path to the file containing the current data vs potential. The columns may be reversed in order. In the @@ -84,8 +84,8 @@ def read( wl_series = DataSeries("wavelength / [nm]", "nm", wl) # The reference spectrum spans a space defined by wavelength: reference = Field( - "reference", - "counts", + name="reference", + unit_name="counts", axes_series=[wl_series], data=ref_signal, ) @@ -159,7 +159,7 @@ def read( path_to_ref_spec_file (Path or str): The full path to the file containing the wavelenth data, together usually with the adsorption-free spectrum. The length of the columns should be the same as in the spectrum data - but in practice is a few points longer. The excess points at the ends + but in practice is a few points longer. The excess points at the starts of the columns are discarded. path_to_t_V_file (Path or str): The full path to the file containing the potential data vs time. diff --git a/src/ixdat/spectra.py b/src/ixdat/spectra.py index 19a7c2a6..458368d4 100644 --- a/src/ixdat/spectra.py +++ b/src/ixdat/spectra.py @@ -306,7 +306,9 @@ def __getitem__(self, key): spectrum_as_dict = self.as_dict() del spectrum_as_dict["field_id"] spectrum_as_dict["field"] = Field( - name=self.y_name, + # note that it's important in some cases that the spectrum does not have + # the same name as the spectrum series: + name=self.y_name + "_" + str(key), unit_name=self.field.unit_name, data=self.y[key], axes_series=[self.xseries], diff --git a/src/ixdat/techniques/spectroelectrochemistry.py b/src/ixdat/techniques/spectroelectrochemistry.py index d54bcb5b..8a977146 100644 --- a/src/ixdat/techniques/spectroelectrochemistry.py +++ b/src/ixdat/techniques/spectroelectrochemistry.py @@ -118,7 +118,7 @@ def calc_dOD(self, V_ref=None, t_ref=None, index_ref=None): ) return dOD_series - def get_spectrum(self, V=None, t=None, index=None): + def get_spectrum(self, V=None, t=None, index=None, name=None): """Return the Spectrum at a given potential V, time t, or index Exactly one of V, t, and index should be given. If V (t) is out of the range of @@ -129,6 +129,7 @@ def get_spectrum(self, V=None, t=None, index=None): be monotonically increasing for this to work. t (float): The time at which to get the spectrum index (int): The index of the spectrum + name (str): Optional. name to give the new spectrum if interpolated Return Spectrum: The spectrum. The data is (spectrum.x, spectrum.y) """ @@ -138,7 +139,7 @@ def get_spectrum(self, V=None, t=None, index=None): index = int(np.argmax(self.t == t)) if index: # then we're done: return self.spectrum_series[index] - # now, we have to interpolate: + # otherwise, we have to interpolate: counts = self.spectra.data end_spectra = (self.spectrum_series[0].y, self.spectrum_series[-1].y) if V: @@ -147,18 +148,20 @@ def get_spectrum(self, V=None, t=None, index=None): ) # FIXME: This requires that potential and spectra have same tseries! y = counts_interpolater(V) + name = name or f"{self.spectra.name}_{V}V" elif t: t_spec = self.spectra.axes_series[0].t counts_interpolater = interp1d( t_spec, counts, axis=0, fill_value=end_spectra, bounds_error=False ) y = counts_interpolater(t) + name = name or f"{self.spectra.name}_{t}s" else: raise ValueError(f"Need t or V or index to select a spectrum!") field = Field( data=y, - name=self.spectra.name, + name=name, unit_name=self.spectra.unit_name, axes_series=[self.wavelength], ) @@ -207,8 +210,8 @@ def track_wavelength(self, wl, width=10, V_ref=None, t_ref=None, index_ref=None) The cacheing adds wl_str to the SECMeasurement's data series, where wl_str = "w" + int(wl) This is dOD. The raw is also added as wl_str + "_raw". - So, to get the raw counts for a specific wavelength, call this function as - and then use __getitem__, as in: sec_meas[wl_str + "_raw"] + So, to get the raw counts for a specific wavelength, call this function and + then use __getitem__, as in: sec_meas[wl_str + "_raw"] If V_ref, t_ref, or index_ref are provided, they specify what to reference dOD to. Otherwise, dOD is referenced to the SECMeasurement's reference_spectrum. @@ -216,7 +219,8 @@ def track_wavelength(self, wl, width=10, V_ref=None, t_ref=None, index_ref=None) wl (float): The wavelength to track in [nm] width (float): The width around wl to average. For example, if wl=400 and width = 20, the spectra will be averaged between 390 and 410 nm to get - the values. Defaults to 10 + the values. Defaults to 10. To interpolate at the exact wavelength + rather than averaging, specify `width=0`. V_ref (float): The potential at which to get the reference spectrum t_ref (float): The time at which to get the reference spectrum index_ref (int): The index of the reference spectrum From 9e1112f88ab41859e56f84942559718d73f97ef3 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Tue, 9 Nov 2021 17:40:45 +0000 Subject: [PATCH 077/118] clean up documentation --- .../reader_testers/test_msrh_sec_reader.py | 12 +-- .../reader_testers/test_zilien_reader.py | 6 +- .../test_zilien_spectrum_reader.py | 5 +- docs/source/exporter_docs/index.rst | 2 +- docs/source/reader_docs/index.rst | 10 +-- docs/source/technique_docs/ec_ms.rst | 10 ++- .../technique_docs/electrochemistry.rst | 4 +- docs/source/technique_docs/mass_spec.rst | 2 +- src/ixdat/exporters/spectrum_exporter.py | 17 +++-- src/ixdat/measurements.py | 10 ++- src/ixdat/plotters/ec_plotter.py | 9 ++- src/ixdat/plotters/ecms_plotter.py | 2 + src/ixdat/plotters/ms_plotter.py | 12 +-- src/ixdat/plotters/sec_plotter.py | 17 +---- src/ixdat/plotters/spectrum_plotter.py | 4 +- src/ixdat/readers/biologic.py | 8 +- src/ixdat/readers/ec_ms_pkl.py | 13 +--- src/ixdat/readers/reading_tools.py | 4 +- src/ixdat/readers/zilien.py | 9 +-- src/ixdat/spectra.py | 2 +- src/ixdat/techniques/cv.py | 11 +-- src/ixdat/techniques/deconvolution.py | 5 +- src/ixdat/techniques/ec.py | 76 +++++++++---------- src/ixdat/techniques/ec_ms.py | 6 +- src/ixdat/techniques/ms.py | 27 +------ .../techniques/spectroelectrochemistry.py | 17 +---- tests/test_dummy.py | 4 +- 27 files changed, 111 insertions(+), 193 deletions(-) diff --git a/development_scripts/reader_testers/test_msrh_sec_reader.py b/development_scripts/reader_testers/test_msrh_sec_reader.py index f5727ccc..b3f91d6c 100644 --- a/development_scripts/reader_testers/test_msrh_sec_reader.py +++ b/development_scripts/reader_testers/test_msrh_sec_reader.py @@ -35,17 +35,9 @@ sec_reloaded.plot_vs_potential(cmap_name="jet") -axes = sec_meas.plot_measurement( - V_ref=0.4, - cmap_name="jet", - make_colorbar=True, -) +axes = sec_meas.plot_measurement(V_ref=0.4, cmap_name="jet", make_colorbar=True,) -ax = sec_meas.plot_waterfall( - V_ref=0.4, - cmap_name="jet", - make_colorbar=True, -) +ax = sec_meas.plot_waterfall(V_ref=0.4, cmap_name="jet", make_colorbar=True,) ax.get_figure().savefig("sec_waterfall.png") axes2 = sec_meas.plot_vs_potential(V_ref=0.66, cmap_name="jet", make_colorbar=False) diff --git a/development_scripts/reader_testers/test_zilien_reader.py b/development_scripts/reader_testers/test_zilien_reader.py index fbb84386..c5180f7e 100644 --- a/development_scripts/reader_testers/test_zilien_reader.py +++ b/development_scripts/reader_testers/test_zilien_reader.py @@ -5,11 +5,7 @@ "2020-07-29 10_30_39 Pt_poly_cv_01_02_CVA_C01.mpt" ) -m = ECMeasurement.read( - path_to_file, - reader="biologic", - name="ec_tools_test", -) +m = ECMeasurement.read(path_to_file, reader="biologic", name="ec_tools_test",) # ax = m.plot() diff --git a/development_scripts/reader_testers/test_zilien_spectrum_reader.py b/development_scripts/reader_testers/test_zilien_spectrum_reader.py index 77f7dbd6..53889b37 100644 --- a/development_scripts/reader_testers/test_zilien_spectrum_reader.py +++ b/development_scripts/reader_testers/test_zilien_spectrum_reader.py @@ -6,10 +6,7 @@ / "mass scan started at measurement time 0001700.tsv" ) -spec = Spectrum.read( - path_to_file, - reader="zilien_spec", -) +spec = Spectrum.read(path_to_file, reader="zilien_spec",) spec.plot(color="k") diff --git a/docs/source/exporter_docs/index.rst b/docs/source/exporter_docs/index.rst index 47f9f051..64b3c8dd 100644 --- a/docs/source/exporter_docs/index.rst +++ b/docs/source/exporter_docs/index.rst @@ -28,7 +28,7 @@ The ``ecms_exporter`` module :members: The ``spectrum_exporter`` module -........................... +................................ .. _spectrum-exporter: .. automodule:: ixdat.exporters.spectrum_exporter diff --git a/docs/source/reader_docs/index.rst b/docs/source/reader_docs/index.rst index 150c28e2..9f6f56d1 100644 --- a/docs/source/reader_docs/index.rst +++ b/docs/source/reader_docs/index.rst @@ -1,7 +1,7 @@ .. _readers: Readers: getting data into ``ixdat`` -================================== +==================================== Source: https://github.com/ixdat/ixdat/tree/user_ready/src/ixdat/readers A full list of the readers thus accessible and their names can be viewed by typing: @@ -34,7 +34,7 @@ in the future it will also have a reader which downloads from the website given setup and date. The ``cinfdata`` module -........................ +....................... .. automodule:: ixdat.readers.cinfdata :members: @@ -51,7 +51,7 @@ The ``biologic`` module :members: The ``autolab`` module -........................ +...................... .. automodule:: ixdat.readers.autolab :members: @@ -79,7 +79,7 @@ These are readers which by default return an ``ECMSMeasurement``. (See :ref:`ec-ms`) The ``zilien`` module -........................ +..................... .. automodule:: ixdat.readers.zilien :members: @@ -94,7 +94,7 @@ The ``ec_ms_pkl`` module EC-MS and sub-techniques ------------------------ These are readers which by default return a ``SpectroECMeasurement``. -(See :ref:`s-ec`) +(See :ref:`sec`) The ``msrh_sec`` module ....................... diff --git a/docs/source/technique_docs/ec_ms.rst b/docs/source/technique_docs/ec_ms.rst index 2400ae67..f8484cd6 100644 --- a/docs/source/technique_docs/ec_ms.rst +++ b/docs/source/technique_docs/ec_ms.rst @@ -5,7 +5,7 @@ Electrochemistry - Mass Spectrometry (EC-MS) The main class for EC-MS data is the ECMSMeasurement. -It comes with the :ref:`EC-MS plotter ` which makes EC-MS plots like this one: +It comes with the :ref:`EC-MS plotter ` which makes EC-MS plots like this one: .. figure:: ../figures/ec_ms_annotated.svg :width: 600 @@ -24,7 +24,7 @@ based on an electrochemical cyclic voltammatry program that are implemented in ` Deconvolution, described in a publication under review, is implemented in the deconvolution module, in a class inheriting from ``ECMSMeasurement``. -ixdat will soon have all the functionality and more for EC-MS data and analysis as the +``ixdat`` will soon have all the functionality and more for EC-MS data and analysis as the legacy `EC_MS `_ package. This includes the tools behind the EC-MS analysis and visualization in the puplications: @@ -38,9 +38,11 @@ behind the EC-MS analysis and visualization in the puplications: - Anna Winiwarter, et al. **CO as a Probe Molecule to Study Surface Adsorbates during Electrochemical Oxidation of Propene**. `ChemElectroChem, 2021 `_. -- Soren B. Scott, et al. **Tracking oxygen atoms in electrochemical CO oxidation –Part I: Oxygen exchange via CO2 hydration**. `Electrochimica Acta, 2021 `_. +``ixdat`` is used for the following articles: -- Soren B. Scott, et al. **Tracking oxygen atoms in electrochemical CO oxidation –Part II: Lattice oxygen reactivity in oxides of Pt and Ir**. `Electrochimica Acta, 2021 `_. +- Soren B. Scott, et al. **Tracking oxygen atoms in electrochemical CO oxidation –Part I: Oxygen exchange via CO2 hydration**. `Electrochimica Acta, 2021a `_. + +- Soren B. Scott, et al. **Tracking oxygen atoms in electrochemical CO oxidation –Part II: Lattice oxygen reactivity in oxides of Pt and Ir**. `Electrochimica Acta, 2021b `_. The ``ec_ms`` module diff --git a/docs/source/technique_docs/electrochemistry.rst b/docs/source/technique_docs/electrochemistry.rst index f9b10a8b..92be4e37 100644 --- a/docs/source/technique_docs/electrochemistry.rst +++ b/docs/source/technique_docs/electrochemistry.rst @@ -37,7 +37,7 @@ Source: https://github.com/ixdat/ixdat/tree/user_ready/src/ixdat/techniques/ec.p :width: 600 :alt: Example plots. left: ``ECMeasurement.plot_vs_potential()`` right: ``ECMeasurement.plot_measurement()`` - left: ``ECMeasurement.plot_vs_potential()`` right: ``ECMeasurement.plot_measurement()``. `Tutorial `_ + left: ``ECMeasurement.plot_vs_potential()`` right: ``ECMeasurement.plot_measurement()``. `See tutorial `_ .. automodule:: ixdat.techniques.ec :members: @@ -52,7 +52,7 @@ Source: https://github.com/ixdat/ixdat/tree/user_ready/src/ixdat/techniques/cv.p :width: 300 :alt: Example ``CyclicVoltammagramDiff`` plot - ``CyclicVoltammagramDiff.plot()``. `Tutorial `_. + output of ``CyclicVoltammagramDiff.plot()``. `Tutorial `_. .. automodule:: ixdat.techniques.cv :members: \ No newline at end of file diff --git a/docs/source/technique_docs/mass_spec.rst b/docs/source/technique_docs/mass_spec.rst index b96829a7..505c9dd0 100644 --- a/docs/source/technique_docs/mass_spec.rst +++ b/docs/source/technique_docs/mass_spec.rst @@ -9,7 +9,7 @@ types of data - spectra, where intensity is taken while scanning over m/z, and mass intensity detection (MID) where the intensity of a small set of m/z values are tracked in time. -The main TechniqueMeasurement class for MID data is the :ref:`MSMeasurement`. +The main TechniqueMeasurement class for MID data is the ``MSMeasurement``. Classes dealing with spectra are under development. diff --git a/src/ixdat/exporters/spectrum_exporter.py b/src/ixdat/exporters/spectrum_exporter.py index df996f67..dfc98c7b 100644 --- a/src/ixdat/exporters/spectrum_exporter.py +++ b/src/ixdat/exporters/spectrum_exporter.py @@ -65,14 +65,15 @@ def __init__(self, spectrum_series, delim=","): def export(self, spectrum_series=None, path_to_file=None, spectra_as_rows=True): """Export spectrum to path_to_file. - spectrum (Spectrum): The spectrum to export if different from self.spectrum - path_to_file (str or Path): The path of the file to export to. Note that if a - file already exists with this path, it will be overwritten. - spectra_as_rows (bool): This specifies the orientation of the data exported. - If True, the scanning variabe (e.g. wavelength) increases to the right and - the time variable increases downward. If False, the scanning variable - increases downwards and the time variable increases to the right. Either - way it is clarified in the file header. Defaults to True. + Args: + spectrum (Spectrum): The spectrum to export if different from self.spectrum + path_to_file (str or Path): The path of the file to export to. Note that if a + file already exists with this path, it will be overwritten. + spectra_as_rows (bool): This specifies the orientation of the data exported. + If True, the scanning variabe (e.g. wavelength) increases to the right + and the time variable increases downward. If False, the scanning + variable increases downwards and the time variable increases to the + right. Either way it is clarified in the file header. Defaults to True. """ spectrum_series = spectrum_series or self.spectrum_series diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index 81c5be03..0fe8f8f5 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -681,8 +681,8 @@ def select_values(self, *args, **kwargs): can be single acceptable values or lists of acceptable values. In the latter case, each acceptable value is selected for on its own and the resulting measurements added together. - # FIXME: That is sloppy because it mutliplies the number of DataSeries - containing the same amount of data. + FIXME: That is sloppy because it multiplies the number of DataSeries + FIXME: containing the same amount of data. If no key-word is given, the series name is assumed to be the default selector, which is named by self.sel_str. Multiple criteria are applied sequentially, i.e. you get the intersection of satisfying parts. @@ -693,6 +693,7 @@ def select_values(self, *args, **kwargs): kwargs (dict): Each key-word arguments is understood as the name of a series and its acceptable value(s). """ + if len(args) >= 1: if not self.sel_str: raise BuildError( @@ -717,7 +718,7 @@ def select_values(self, *args, **kwargs): return new_measurement def select(self, *args, tspan=None, **kwargs): - """`cut` (with tspan) and `select_values` (with *args and/or **kwargs).""" + """`cut` (with tspan) and `select_values` (with args and/or kwargs).""" new_measurement = self if tspan: new_measurement = new_measurement.cut(tspan=tspan) @@ -727,7 +728,7 @@ def select(self, *args, tspan=None, **kwargs): @property def tspan(self): - """Return (t_start, t_finish) for all data in the measurement""" + """Return `(t_start, t_finish)` interval including all data in the measurement""" t_start = None t_finish = None for tcol in self.time_names: @@ -756,6 +757,7 @@ def __add__(self, other): all the raw series (or their placeholders) are just stored in the lists. TODO: Make sure with tests this is okay, differentiate using | operator if not. """ + # First we prepare a dictionary for all but the series_list. # This has both dicts, but prioritizes self's dict for all that appears twice. obj_as_dict = self.as_dict() diff --git a/src/ixdat/plotters/ec_plotter.py b/src/ixdat/plotters/ec_plotter.py index 71f7da22..a4a4b48b 100644 --- a/src/ixdat/plotters/ec_plotter.py +++ b/src/ixdat/plotters/ec_plotter.py @@ -76,12 +76,12 @@ def plot_vs_potential( This can actually plot with anything on the x-axis, by specifying what you want on the x-axis using V_str. The y-axis variable, which can be specified by J_str, is interpolated onto the time corresponding to the x-axis variable. - TODO: This is a special case of the not-yet-implemented generalized - `plot_vs`. Consider an inheritance structure to reduce redundancy in - future plotters. + TODO: This is a special case of the not-yet-implemented generalized + TODO: `plot_vs`. Consider an inheritance structure to reduce redundancy in + TODO: future plotters. All arguments are optional. By default it will plot current vs potential in black on a single axis for the whole experiment. - TODO: color gradient (cmap=inferno) from first to last cycle. + TODO: color gradient (cmap=inferno) from first to last cycle. Args: measurement (Measurement): What to plot. Defaults to the measurement the @@ -96,6 +96,7 @@ def plot_vs_potential( Returns matplotlib.pyplot.axis: The axis plotted on. """ + measurement = measurement or self.measurement V_str = V_str or measurement.potential.name J_str = J_str or measurement.current.name diff --git a/src/ixdat/plotters/ecms_plotter.py b/src/ixdat/plotters/ecms_plotter.py index 699fd505..160530b9 100644 --- a/src/ixdat/plotters/ecms_plotter.py +++ b/src/ixdat/plotters/ecms_plotter.py @@ -42,6 +42,7 @@ def plot_measurement( - variable subplot sizing (emphasizing EC or MS) - plotting of calibrated data (mol_list instead of mass_list) - units! + Args: measurement (ECMSMeasurement): defaults to the measurement to which the plotter is bound (self.measurement) @@ -165,6 +166,7 @@ def plot_vs_potential( - variable subplot sizing (emphasizing EC or MS) - plotting of calibrated data (mol_list instead of mass_list) - units! + Args: measurement (ECMSMeasurement): defaults to the measurement to which the plotter is bound (self.measurement) diff --git a/src/ixdat/plotters/ms_plotter.py b/src/ixdat/plotters/ms_plotter.py index bcef2d70..201143e8 100644 --- a/src/ixdat/plotters/ms_plotter.py +++ b/src/ixdat/plotters/ms_plotter.py @@ -29,13 +29,14 @@ def plot_measurement( ): """Plot m/z signal vs time (MID) data and return the axis. - There are four ways to specify what to plot. Only specify one of these: + There are four ways to specify what to plot. Only specify one of these:: mass_list: Uncalibrated signals in [(u/n/p)A] on on axis mass_lists: Uncalibrated signals in [(u/n/p)A] on two axes mol_list: Calibrated signals in [(u/n/p)mol/s] on on axis mol_lists: Calibrated signals in [(u/n/p)mol/s] on two axes - Two axes refers to seperate left and right y-axes. Default is to use all - availeable masses as mass_list. + + Two axes refers to separate left and right y-axes. Default is to use all + available masses as mass_list. Args: measurement (MSMeasurement): defaults to the one that initiated the plotter @@ -157,13 +158,14 @@ def plot_vs( ): """Plot m/z signal (MID) data against a specified variable and return the axis. - There are four ways to specify what to plot. Only specify one of these: + There are four ways to specify what to plot. Only specify one of these:: mass_list: Uncalibrated signals in [(u/n/p)A] on on axis mass_lists: Uncalibrated signals in [(u/n/p)A] on two axes mol_list: Calibrated signals in [(u/n/p)mol/s] on on axis mol_lists: Calibrated signals in [(u/n/p)mol/s] on two axes + Two axes refers to seperate left and right y-axes. Default is to use all - availeable masses as mass_list. + available masses as mass_list. Args: x_name (str): Name of the variable to plot on the x-axis diff --git a/src/ixdat/plotters/sec_plotter.py b/src/ixdat/plotters/sec_plotter.py index c4c69a2d..fc0985ec 100644 --- a/src/ixdat/plotters/sec_plotter.py +++ b/src/ixdat/plotters/sec_plotter.py @@ -62,16 +62,9 @@ def plot_measurement( measurement = measurement or self.measurement if not axes: - axes = self.new_two_panel_axes( - n_bottom=2, - n_top=1, - emphasis="top", - ) + axes = self.new_two_panel_axes(n_bottom=2, n_top=1, emphasis="top",) self.ec_plotter.plot_measurement( - measurement=measurement, - axes=[axes[1], axes[2]], - tspan=tspan, - **kwargs, + measurement=measurement, axes=[axes[1], axes[2]], tspan=tspan, **kwargs, ) dOD_series = measurement.calc_dOD(V_ref=V_ref, t_ref=t_ref) @@ -175,11 +168,7 @@ def plot_vs_potential( measurement = measurement or self.measurement if not axes: - axes = self.new_two_panel_axes( - n_bottom=1, - n_top=1, - emphasis="top", - ) + axes = self.new_two_panel_axes(n_bottom=1, n_top=1, emphasis="top",) self.ec_plotter.plot_vs_potential( measurement=measurement, diff --git a/src/ixdat/plotters/spectrum_plotter.py b/src/ixdat/plotters/spectrum_plotter.py index 072657f1..57ba263b 100644 --- a/src/ixdat/plotters/spectrum_plotter.py +++ b/src/ixdat/plotters/spectrum_plotter.py @@ -95,7 +95,7 @@ def heat_plot_vs( spectrum_series (SpectrumSeries): The spectrum series to be plotted, if different from self.spectrum_series. FIXME: spectrum_series needs to actually be a Measurement to have other - series to plot against if vs isn't in field.series_axes + FIXME: series to plot against if vs isn't in field.series_axes field (Field): The field to be plotted, if different from spectrum_series.field xspan (iterable): The span of the spectral data to plot @@ -175,7 +175,7 @@ def plot_waterfall( spectrum_series (SpectrumSeries): The spectrum series to be plotted, if different from self.spectrum_series. FIXME: spectrum_series needs to actually be a Measurement to have other - series to plot against if vs isn't in field.series_axes + FIXME: ...series to plot against if vs isn't in field.series_axes field (Field): The field to be plotted, if different from spectrum_series.field ax (matplotlib Axis): The axes to plot on. A new one is made by default. diff --git a/src/ixdat/readers/biologic.py b/src/ixdat/readers/biologic.py index 3e6d079f..6dbd8d5c 100644 --- a/src/ixdat/readers/biologic.py +++ b/src/ixdat/readers/biologic.py @@ -116,10 +116,7 @@ def read(self, path_to_file, name=None, cls=None, **kwargs): f"This reader only works for files with a '{t_str}' column" ) tseries = TimeSeries( - name=t_str, - data=self.column_data[t_str], - tstamp=self.tstamp, - unit_name="s", + name=t_str, data=self.column_data[t_str], tstamp=self.tstamp, unit_name="s", ) data_series_list = [tseries] for column_name, data in self.column_data.items(): @@ -308,8 +305,7 @@ def get_column_unit(column_name): ) ec_measurement = Measurement.read( - reader="biologic", - path_to_file=path_to_test_file, + reader="biologic", path_to_file=path_to_test_file, ) t, v = ec_measurement.grab_potential(tspan=[0, 100]) diff --git a/src/ixdat/readers/ec_ms_pkl.py b/src/ixdat/readers/ec_ms_pkl.py index ebc2fbee..dd842e3e 100644 --- a/src/ixdat/readers/ec_ms_pkl.py +++ b/src/ixdat/readers/ec_ms_pkl.py @@ -37,11 +37,7 @@ def read(self, file_path, cls=None, **kwargs): def measurement_from_ec_ms_dataset( - ec_ms_dict, - name=None, - cls=ECMSMeasruement, - reader=None, - **kwargs, + ec_ms_dict, name=None, cls=ECMSMeasruement, reader=None, **kwargs, ): """Return an ixdat Measurement with the data from an EC_MS data dictionary. @@ -100,12 +96,7 @@ def measurement_from_ec_ms_dataset( print(f"Not including '{col}' due to mismatch size with {tseries}") continue cols_list.append( - ValueSeries( - name=v_name, - data=data, - unit_name=unit_name, - tseries=tseries, - ) + ValueSeries(name=v_name, data=data, unit_name=unit_name, tseries=tseries,) ) obj_as_dict = dict( diff --git a/src/ixdat/readers/reading_tools.py b/src/ixdat/readers/reading_tools.py index 7c9f25df..6d29e751 100644 --- a/src/ixdat/readers/reading_tools.py +++ b/src/ixdat/readers/reading_tools.py @@ -14,9 +14,7 @@ def timestamp_string_to_tstamp( - timestamp_string, - form=None, - forms=(STANDARD_TIMESTAMP_FORM,), + timestamp_string, form=None, forms=(STANDARD_TIMESTAMP_FORM,), ): """Return the unix timestamp as a float by parsing timestamp_string diff --git a/src/ixdat/readers/zilien.py b/src/ixdat/readers/zilien.py index 21d696dc..f80e0473 100644 --- a/src/ixdat/readers/zilien.py +++ b/src/ixdat/readers/zilien.py @@ -116,11 +116,7 @@ def read(self, path_to_spectrum, cls=None, **kwargs): if path_to_spectrum: self.path_to_spectrum = Path(path_to_spectrum) cls = cls or MSSpectrum - df = pd.read_csv( - path_to_spectrum, - header=9, - delimiter="\t", - ) + df = pd.read_csv(path_to_spectrum, header=9, delimiter="\t",) x_name = "Mass [AMU]" y_name = "Current [A]" x = df[x_name].to_numpy() @@ -170,8 +166,7 @@ def read(self, path_to_spectrum, cls=None, **kwargs): ) ecms_measurement = Measurement.read( - reader="zilien", - path_to_file=path_to_test_file, + reader="zilien", path_to_file=path_to_test_file, ) ecms_measurement.plot_measurement() diff --git a/src/ixdat/spectra.py b/src/ixdat/spectra.py index 458368d4..9636e29a 100644 --- a/src/ixdat/spectra.py +++ b/src/ixdat/spectra.py @@ -106,7 +106,7 @@ def data_objects(self): For a field to be correctly saved and loaded, its axes_series must be saved first. So there are three series in the data_objects to return FIXME: with backend-specifying id's, field could check for itself whether - its axes_series are already in the database. + FIXME: its axes_series are already in the database. """ return self.series_list diff --git a/src/ixdat/techniques/cv.py b/src/ixdat/techniques/cv.py index 4226e4d0..9a3cd6d0 100644 --- a/src/ixdat/techniques/cv.py +++ b/src/ixdat/techniques/cv.py @@ -14,8 +14,8 @@ class CyclicVoltammagram(ECMeasurement): 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`. + 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() """ @@ -133,12 +133,7 @@ def select_sweep(self, vspan, t_i=None): 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, - ) + tspan = tspan_passing_through(t=self.t, v=self.v, vspan=vspan, t_i=t_i,) return self.cut(tspan=tspan) def integrate(self, item, tspan=None, vspan=None, ax=None): diff --git a/src/ixdat/techniques/deconvolution.py b/src/ixdat/techniques/deconvolution.py index ebd4e7c8..74aded7a 100644 --- a/src/ixdat/techniques/deconvolution.py +++ b/src/ixdat/techniques/deconvolution.py @@ -97,10 +97,7 @@ class Kernel: # TODO: Make class inherit from Measurement, add properties to store kernel # TODO: Reference equations to paper. def __init__( - self, - parameters={}, - MS_data=None, - EC_data=None, + self, parameters={}, MS_data=None, EC_data=None, ): """Initializes a Kernel object either in functional form by defining the mass transport parameters or in the measured form by passing of EC-MS diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index 8bcc6835..5c58d8d7 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -11,9 +11,8 @@ class ECMeasurement(Measurement): """Class implementing electrochemistry measurements - TODO: - Implement a unit library for current and potential, A_el and RE_vs_RHE - so that e.g. current can be seamlessly normalized to mass OR area. + 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., @@ -22,36 +21,41 @@ class ECMeasurement(Measurement): 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: - 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`). + 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. + 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: + `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_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: + 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. + 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`) + 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 @@ -60,9 +64,10 @@ class ECMeasurement(Measurement): - `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 + - `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 @@ -119,7 +124,7 @@ def __init__( Args: name (str): The name of the measurement TODO: Decide if metadata needs the json string option. - See: https://github.com/ixdat/ixdat/pull/1#discussion_r546436991 + 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 @@ -155,12 +160,13 @@ def __init__( 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 potential `self[self.V_str]` by default + 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, @@ -211,9 +217,7 @@ def __init__( ): self.series_list.append( ConstantValue( - name=self.raw_current_names[0], - unit_name="mA", - value=0, + name=self.raw_current_names[0], unit_name="mA", value=0, ) ) self._populate_constants() # So that OCP currents are included as 0. @@ -228,11 +232,7 @@ def __init__( ] ): self.series_list.append( - ConstantValue( - name=self.cycle_names[0], - unit_name=None, - value=0, - ) + ConstantValue(name=self.cycle_names[0], unit_name=None, value=0,) ) self._populate_constants() # So that everything has a cycle number @@ -329,7 +329,7 @@ def _find_or_build_raw_potential(self): 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 + 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 @@ -445,11 +445,12 @@ def potential(self): 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` + `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. + `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 @@ -482,8 +483,8 @@ def current(self): 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. + 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] @@ -577,10 +578,7 @@ def _build_selector(self, sel_str=None): 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, + name=sel_str, unit_name="", data=selector, tseries=self.potential.tseries, ) self[self.sel_str] = selector_series # TODO: Better cache'ing. This gets saved. diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index f04abe07..21628896 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -120,11 +120,7 @@ def ecms_calibration(self, mol, mass, n_el, tspan, tspan_bg=None): n = Q / (n_el * FARADAY_CONSTANT) F = Y / n cal = MSCalResult( - name=f"{mol}_{mass}", - mol=mol, - mass=mass, - cal_type="ecms_calibration", - F=F, + name=f"{mol}_{mass}", mol=mol, mass=mass, cal_type="ecms_calibration", F=F, ) return cal diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index ecf1e827..fa5d56d8 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -22,10 +22,7 @@ class MSMeasurement(Measurement): """Class implementing raw MS functionality""" extra_column_attrs = { - "ms_meaurements": { - "mass_aliases", - "signal_bgs", - }, + "ms_meaurements": {"mass_aliases", "signal_bgs",}, } def __init__( @@ -165,12 +162,7 @@ def grab_flux( return x, n_dot def grab_flux_for_t( - self, - mol, - t, - tspan_bg=None, - removebackground=False, - include_endpoints=False, + self, mol, t, tspan_bg=None, removebackground=False, include_endpoints=False, ): """Return the flux of mol (calibrated signal) in [mol/s] for a given time vec @@ -255,12 +247,7 @@ class MSCalResult(Saveable): column_attrs = {"name", "mol", "mass", "cal_type", "F"} def __init__( - self, - name=None, - mol=None, - mass=None, - cal_type=None, - F=None, + self, name=None, mol=None, mass=None, cal_type=None, F=None, ): super().__init__() self.name = name @@ -380,13 +367,7 @@ def calc_n_dot_0( return n_dot def gas_flux_calibration( - self, - measurement, - mol, - mass, - tspan=None, - tspan_bg=None, - ax=None, + self, measurement, mol, mass, tspan=None, tspan_bg=None, ax=None, ): """ Args: diff --git a/src/ixdat/techniques/spectroelectrochemistry.py b/src/ixdat/techniques/spectroelectrochemistry.py index 8a977146..2b5d43ba 100644 --- a/src/ixdat/techniques/spectroelectrochemistry.py +++ b/src/ixdat/techniques/spectroelectrochemistry.py @@ -26,10 +26,7 @@ def reference_spectrum(self): return self._reference_spectrum def set_reference_spectrum( - self, - spectrum=None, - t_ref=None, - V_ref=None, + self, spectrum=None, t_ref=None, V_ref=None, ): """Set the spectrum used as the reference when calculating dOD. @@ -60,9 +57,7 @@ def spectra(self): def spectrum_series(self): """The SpectrumSeries that is the spectra of the SEC Measurement""" return SpectrumSeries.from_field( - self.spectra, - tstamp=self.tstamp, - name=self.name + " spectra", + self.spectra, tstamp=self.tstamp, name=self.name + " spectra", ) @property @@ -168,13 +163,7 @@ def get_spectrum(self, V=None, t=None, index=None, name=None): return Spectrum.from_field(field, tstamp=self.tstamp) def get_dOD_spectrum( - self, - V=None, - t=None, - index=None, - V_ref=None, - t_ref=None, - index_ref=None, + self, V=None, t=None, index=None, V_ref=None, t_ref=None, index_ref=None, ): """Return the delta optical density Spectrum given a point and reference point. diff --git a/tests/test_dummy.py b/tests/test_dummy.py index a6efc83f..2e1fbc22 100644 --- a/tests/test_dummy.py +++ b/tests/test_dummy.py @@ -9,9 +9,7 @@ def dont_test_blank_measurement(): # FIXME: tox can't handle matplotlib from ixdat.measurements import Measurement - meas = Measurement( - name="blank", - ) + meas = Measurement(name="blank",) print(meas) assert len(meas.value_names) == 0 From ded9e3d7a4530cbaa0dbc28c4c73cdff940e57dc Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Tue, 9 Nov 2021 18:01:04 +0000 Subject: [PATCH 078/118] fix rtd error --- docs/requirements.txt | 3 ++- requirements-dev.txt | 3 ++- setup.py | 9 --------- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 2a1b2fb5..638c60bc 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -3,4 +3,5 @@ sphinx sphinx_rtd_theme readthedocs-sphinx-search -ixdat \ No newline at end of file +ixdat +docutils<0.18 # see https://github.com/sphinx-doc/sphinx/issues/9727 \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index 1f0a43af..a401181a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,6 +5,7 @@ sphinx-rtd-theme sphinx_automodapi EC_MS flake8 +twine +setuputils tox -pytest invoke diff --git a/setup.py b/setup.py index 13b234d7..6adca55e 100644 --- a/setup.py +++ b/setup.py @@ -1,13 +1,4 @@ """Initial setup.py - -TODO: This file is rudimentary and setup mainly to enable tox to -run. The main points missing are: - -* Proper and correct trove classifiers (https://pypi.org/classifiers/) -* A read through of metadata in ``__init__.py`` -* Handling of data files (necessary for the package to run, not for - development) when we start to get those - """ import os From a4631fddbaf1badcdbf7928b6ba0f6c07f4076ee Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Tue, 9 Nov 2021 18:34:47 +0000 Subject: [PATCH 079/118] ------ ixdat v0.1.5 -------- --- README.rst | 10 +++++++--- docs/source/introduction.rst | 2 +- src/ixdat/__init__.py | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index cf32c57c..0fbb3fb9 100644 --- a/README.rst +++ b/README.rst @@ -45,23 +45,27 @@ thought into every level. :widths: 20 15 50 :header-rows: 1 + * - Measurement technique - Status - Readers - * - Electrochemistry (EC) + * - Electrochemistry - Released - - biologic: .mpt files from Biologic's EC-Lab software - autolab: ascii files from AutoLab's NOVA software - ivium: .txt files from Ivium's IviumSoft software - * - Mass Spectrometry (MS) + * - Mass Spectrometry - Released - - pfeiffer: .dat files from Pfeiffer Vacuum's PVMassSpec software - cinfdata: text export from DTU Physics' cinfdata system - zilien: .tsv files from Spectro Inlets' Zilien software - * - EC-MS + * - Electrochemistry - Mass Spectrometry - Released - - zilien: .tsv files from Spectro Inlets' Zilien software - EC_MS: .pkl files from the legacy EC_MS python package + * - Spectroelectrochemistry + - Released + - - msrh_sec: .csv file sets from Imperial College London's SEC system * - X-ray photoelectron spectroscopy (XPS) - Future - diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst index 6180cc77..514abdd9 100644 --- a/docs/source/introduction.rst +++ b/docs/source/introduction.rst @@ -40,7 +40,7 @@ Supported techniques - - zilien: .tsv files from Spectro Inlets' Zilien software - EC_MS: .pkl files from the legacy EC_MS python package * - :ref:`sec` - - Development + - Released - - msrh_sec: .csv file sets from Imperial College London's SEC system * - X-ray photoelectron spectroscopy (XPS) - Future diff --git a/src/ixdat/__init__.py b/src/ixdat/__init__.py index 533fd6f6..cef1ea29 100644 --- a/src/ixdat/__init__.py +++ b/src/ixdat/__init__.py @@ -1,6 +1,6 @@ """initialize ixdat, giving top-level access to a few of the important structures """ -__version__ = "0.1.4sec" +__version__ = "0.1.5" __title__ = "ixdat" __description__ = "The in-situ experimental data tool" __url__ = "https://github.com/ixdat/ixdat" From a4454eafa3f528808d60b48ce535f6eec5af20e8 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Sun, 21 Nov 2021 11:44:21 +0000 Subject: [PATCH 080/118] RGA and CHI readers (using EC_MS) --- src/ixdat/plotters/value_plotter.py | 9 ++++++- src/ixdat/readers/__init__.py | 4 ++++ src/ixdat/readers/chi.py | 25 +++++++++++++++++++ src/ixdat/readers/ec_ms_pkl.py | 19 +++++++++++---- src/ixdat/readers/rgasoft.py | 37 +++++++++++++++++++++++++++++ 5 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 src/ixdat/readers/chi.py create mode 100644 src/ixdat/readers/rgasoft.py diff --git a/src/ixdat/plotters/value_plotter.py b/src/ixdat/plotters/value_plotter.py index c2f95c6a..d2fa7c2a 100644 --- a/src/ixdat/plotters/value_plotter.py +++ b/src/ixdat/plotters/value_plotter.py @@ -15,7 +15,13 @@ def plot(self, *args, **kwargs): return self.plot_measurement(measurement=self.measurement, *args, **kwargs) def plot_measurement( - self, measurement, v_list=None, tspan=None, ax=None, legend=True, logscale=False + self, + measurement=None, + v_list=None, + tspan=None, + ax=None, + legend=True, + logscale=False, ): """Plot a measurement's values vs time @@ -27,6 +33,7 @@ def plot_measurement( legend (bool): Whether to include a legend. Defaults to True. logscale (bool): Whether to use a log-scaled y-axis. Defaults to False. """ + measurement = measurement or self.measurement if not ax: ax = self.new_ax() v_list = v_list or measurement.value_names diff --git a/src/ixdat/readers/__init__.py b/src/ixdat/readers/__init__.py index c0ba00e4..ac705857 100644 --- a/src/ixdat/readers/__init__.py +++ b/src/ixdat/readers/__init__.py @@ -14,9 +14,11 @@ from .biologic import BiologicMPTReader from .autolab import NovaASCIIReader from .ivium import IviumDatasetReader +from .chi import CHInstrumentsTXTReader # mass spectrometers from .pfeiffer import PVMassSpecReader +from .rgasoft import StanfordRGASoftReader from .cinfdata import CinfdataTXTReader # ec-ms @@ -31,7 +33,9 @@ "biologic": BiologicMPTReader, "autolab": NovaASCIIReader, "ivium": IviumDatasetReader, + "chi": CHInstrumentsTXTReader, "pfeiffer": PVMassSpecReader, + "rgasoft": StanfordRGASoftReader, "cinfdata": CinfdataTXTReader, "zilien": ZilienTSVReader, "zilien_tmp": ZilienTMPReader, diff --git a/src/ixdat/readers/chi.py b/src/ixdat/readers/chi.py new file mode 100644 index 00000000..5ea88286 --- /dev/null +++ b/src/ixdat/readers/chi.py @@ -0,0 +1,25 @@ +"""A reader for text exports from the RGA Software of Stanford Instruments""" + +from EC_MS import Dataset +from .ec_ms_pkl import measurement_from_ec_ms_dataset +from ..techniques import ECMeasurement + + +class CHInstrumentsTXTReader: + path_to_file = None + + def read(self, path_to_file, cls=None): + """Read a .txt file exported by CH Instruments software. + + TODO: Write a new reader that doesn't use the old EC_MS package + + Args: + path_to_file (Path or str): The file to read + cls (Measurement subclass): The class to return. Defaults to ECMeasuremnt + """ + self.path_to_file = path_to_file + cls = cls if (cls and not issubclass(ECMeasurement, cls)) else ECMeasurement + ec_ms_dataset = Dataset(path_to_file, data_type="CHI") + return measurement_from_ec_ms_dataset( + ec_ms_dataset.data, cls=cls, reader=self, technique="EC" + ) diff --git a/src/ixdat/readers/ec_ms_pkl.py b/src/ixdat/readers/ec_ms_pkl.py index dd842e3e..e3ddf055 100644 --- a/src/ixdat/readers/ec_ms_pkl.py +++ b/src/ixdat/readers/ec_ms_pkl.py @@ -37,7 +37,12 @@ def read(self, file_path, cls=None, **kwargs): def measurement_from_ec_ms_dataset( - ec_ms_dict, name=None, cls=ECMSMeasruement, reader=None, **kwargs, + ec_ms_dict, + name=None, + cls=ECMSMeasruement, + reader=None, + technique=None, + **kwargs, ): """Return an ixdat Measurement with the data from an EC_MS data dictionary. @@ -49,7 +54,8 @@ def measurement_from_ec_ms_dataset( ec_ms_dict (dict): The EC_MS data dictionary name (str): Name of the measurement cls (Measurement class): The class to return a measurement of - reader (Reader object): typically what calls this funciton with its read() method + reader (Reader object): The class which read ec_ms_dataset from file + technique (str): The name of the technique """ if "Ewe/V" in ec_ms_dict and "/V" in ec_ms_dict: @@ -96,12 +102,17 @@ def measurement_from_ec_ms_dataset( print(f"Not including '{col}' due to mismatch size with {tseries}") continue cols_list.append( - ValueSeries(name=v_name, data=data, unit_name=unit_name, tseries=tseries,) + ValueSeries( + name=v_name, + data=data, + unit_name=unit_name, + tseries=tseries, + ) ) obj_as_dict = dict( name=name, - technique="EC_MS", + technique=technique or "EC_MS", series_list=cols_list, reader=reader, tstamp=ec_ms_dict["tstamp"], diff --git a/src/ixdat/readers/rgasoft.py b/src/ixdat/readers/rgasoft.py new file mode 100644 index 00000000..33b6e6a5 --- /dev/null +++ b/src/ixdat/readers/rgasoft.py @@ -0,0 +1,37 @@ +"""A reader for text exports from the potentiostat software of CH Instruments""" + +from EC_MS import Dataset +from .reading_tools import timestamp_string_to_tstamp +from .ec_ms_pkl import measurement_from_ec_ms_dataset +from ..techniques import MSMeasurement + + +class StanfordRGASoftReader: + path_to_file = None + + def read(self, path_to_file, cls=None): + """Read a .txt file exported by CH Instruments software. + + TODO: Write a new reader that doesn't use the old EC_MS package + + Args: + path_to_file (Path or str): The file to read + cls (Measurement subclass): The class to return. Defaults to ECMeasuremnt + """ + + # with open(path_to_file, "r") as f: + # timestamp_string = f.readline().strip() + # tstamp = timestamp_string_to_tstamp( + # timestamp_string, + # form="%b %d, %Y %I:%M:%S %p", # like "Mar 05, 2020 09:50:34 AM" + # ) # ^ For later. EC_MS actually gets this right. + + self.path_to_file = path_to_file + cls = cls if (cls and not issubclass(MSMeasurement, cls)) else MSMeasurement + ec_ms_dataset = Dataset( + path_to_file, + data_type="RGA", # tstamp=tstamp + ) + return measurement_from_ec_ms_dataset( + ec_ms_dataset.data, cls=cls, reader=self, technique="MS" + ) From b6df351f25dfb4cf1d431b7250f11a9877991ffa Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Sun, 21 Nov 2021 13:09:34 +0000 Subject: [PATCH 081/118] lookup and export of molar fluxes --- src/ixdat/exporters/ecms_exporter.py | 21 +++++++++++++++++++++ src/ixdat/techniques/ms.py | 23 ++++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/ixdat/exporters/ecms_exporter.py b/src/ixdat/exporters/ecms_exporter.py index 000af082..98693f5c 100644 --- a/src/ixdat/exporters/ecms_exporter.py +++ b/src/ixdat/exporters/ecms_exporter.py @@ -14,3 +14,24 @@ def default_v_list(self): ) return v_list + + def export( + self, + path_to_file=None, + measurement=None, + v_list=None, + tspan=None, + mass_list=None, + mol_list=None, + ): + if not v_list: + if mass_list: + v_list = ECExporter(measurement=self.measurement).default_v_list + else: + v_list = self.default_v_list + if mass_list: + v_list += mass_list + if mol_list: + v_list += [f"n_dot_{mol}" for mol in mol_list] + return super().export(path_to_file, measurement, v_list, tspan) + diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index fa5d56d8..5a216e83 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -13,6 +13,7 @@ MOLECULAR_DIAMETERS, MOLAR_MASSES, ) +from ..data_series import TimeSeries, ValueSeries from ..db import Saveable import re import numpy as np @@ -54,12 +55,15 @@ def __init__( self.tspan_bg = tspan_bg def __getitem__(self, item): - """Adds to Measurement's lookup to check if item is an alias for a mass""" + """Try standard lookup, then check if item is a flux or alias for a mass""" try: return super().__getitem__(item) except SeriesNotFoundError: if item in self.mass_aliases: return self[self.mass_aliases[item]] + if item.startswith("n_"): # it's a flux! + mol = item.split("_")[-1] + return self.get_flux_series(mol) else: raise @@ -182,6 +186,23 @@ def grab_flux_for_t( y = np.interp(t, t_0, y_0) return y + def get_flux_series(self, mol, tspan=None): + """Return a ValueSeries with the calibrated flux of mol during tspan""" + t, n_dot = self.grab_flux(mol, tspan=tspan) + tseries = TimeSeries( + name="n_dot_" + mol + "-t", + unit_name="s", + data=t, + tstamp=self.tstamp + ) + vseries = ValueSeries( + name="n_dot_" + mol, + unit_name="mol/s", + data=n_dot, + tseries=tseries + ) + return vseries + def integrate_signal(self, mass, tspan, tspan_bg, ax=None): """Integrate a ms signal with background subtraction and evt. plotting From 5889fab5255fd5917b09078f1d27a81c2ad059e3 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Sun, 21 Nov 2021 23:34:34 +0000 Subject: [PATCH 082/118] MSCalResults in mol_lists, correct_data (for pyOER) --- src/ixdat/measurements.py | 11 +++++++++++ src/ixdat/plotters/ms_plotter.py | 14 ++++++++++---- src/ixdat/techniques/cv.py | 17 +++++++++++++++++ src/ixdat/techniques/ms.py | 25 ++++++++++++++++++------- 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index 0fe8f8f5..b7886eab 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -449,6 +449,17 @@ def __delitem__(self, series_name): new_series_list.append(s) self._series_list = new_series_list + def correct_data(self, value_name, new_data): + """Replace the old data for ´value_name´ (str) with ´new_data` (np array)""" + old_vseries = self[value_name] + new_vseries = ValueSeries( + name=value_name, + unit_name=old_vseries.unit_name, + data=new_data, + tseries=old_vseries.tseries + ) + self[value_name] = new_vseries + def grab(self, item, tspan=None, include_endpoints=False): """Return a value vector with the corresponding time vector diff --git a/src/ixdat/plotters/ms_plotter.py b/src/ixdat/plotters/ms_plotter.py index 201143e8..f215fb94 100644 --- a/src/ixdat/plotters/ms_plotter.py +++ b/src/ixdat/plotters/ms_plotter.py @@ -85,10 +85,16 @@ def plot_measurement( tspan_bg = specs_this_axis["tspan_bg"] unit = specs_this_axis["unit"] unit_factor = specs_this_axis["unit_factor"] - for v_name in v_list: + for v_or_v_name in v_list: + if isinstance(v_or_v_name, str): + v_name = v_or_v_name + color = STANDARD_COLORS.get(v_name, "k") + else: + v_name = v_or_v_name.name + color = v_or_v_name.color if quantified: t, v = measurement.grab_flux( - v_name, + v_or_v_name, tspan=tspan, tspan_bg=tspan_bg, removebackground=removebackground, @@ -96,7 +102,7 @@ def plot_measurement( ) else: t, v = measurement.grab_signal( - v_name, + v_or_v_name, tspan=tspan, t_bg=tspan_bg, removebackground=removebackground, @@ -107,7 +113,7 @@ def plot_measurement( ax.plot( t, v * unit_factor, - color=STANDARD_COLORS.get(v_name, "k"), + color=color, label=v_name, **kwargs, ) diff --git a/src/ixdat/techniques/cv.py b/src/ixdat/techniques/cv.py index 9a3cd6d0..68cc25bd 100644 --- a/src/ixdat/techniques/cv.py +++ b/src/ixdat/techniques/cv.py @@ -193,6 +193,23 @@ def get_timed_sweeps(self, v_scan_res=5e-4, res_points=10): ) return timed_sweeps + 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 + 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 diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index 5a216e83..dfa32cf0 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -144,17 +144,24 @@ def grab_flux( """Return the flux of mol (calibrated signal) in [mol/s] Args: - mol (str): Name of the molecule. + mol (str or MSCalResult): Name of the molecule or a calibration thereof tspan (list): Timespan for which the signal is returned. tspan_bg (list): Timespan that corresponds to the background signal. If not given, no background is subtracted. removebackground (bool): Whether to remove a pre-set background if available """ - if not self.calibration or mol not in self.calibration: - raise QuantificationError( - f"Can't quantify {mol} in {self}: Not in calibration={self.calibration}" - ) - mass, F = self.calibration.get_mass_and_F(mol) + if isinstance(mol, str): + if not self.calibration or mol not in self.calibration: + raise QuantificationError( + f"Can't quantify {mol} in {self}: " + f"Not in calibration={self.calibration}" + ) + mass, F = self.calibration.get_mass_and_F(mol) + elif isinstance(mol, MSCalResult): + mass = mol.mass + F = mol.F + else: + raise TypeError("mol must be str or MSCalResult") x, y = self.grab_signal( mass, tspan=tspan, @@ -271,7 +278,7 @@ def __init__( self, name=None, mol=None, mass=None, cal_type=None, F=None, ): super().__init__() - self.name = name + self.name = name or f"{mol} at {mass}" self.mol = mol self.mass = mass self.cal_type = cal_type @@ -283,6 +290,10 @@ def __repr__(self): f"mass={self.mass}, F={self.F})" ) + @property + def color(self): + return STANDARD_COLORS[self.mass] + class MSInlet: """A class for describing the inlet to the mass spec From 86367deae379836a424f4de085aee306b52b1806 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Wed, 24 Nov 2021 20:34:30 +0000 Subject: [PATCH 083/118] return J_str, V_str; bg removal for flux --- src/ixdat/measurements.py | 25 +++++++++++++++++++---- src/ixdat/techniques/ec.py | 33 ++++++++++++++++++++++++------ src/ixdat/techniques/ms.py | 41 ++++++++++++++++++++++++++------------ 3 files changed, 76 insertions(+), 23 deletions(-) diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index b7886eab..01999f7b 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -456,11 +456,11 @@ def correct_data(self, value_name, new_data): name=value_name, unit_name=old_vseries.unit_name, data=new_data, - tseries=old_vseries.tseries + tseries=old_vseries.tseries, ) self[value_name] = new_vseries - def grab(self, item, tspan=None, include_endpoints=False): + def grab(self, item, tspan=None, include_endpoints=False, tspan_bg=None): """Return a value vector with the corresponding time vector Grab is the *canonical* way to retrieve numerical time-dependent data from a @@ -482,6 +482,9 @@ def grab(self, item, tspan=None, include_endpoints=False): include_endpoints (bool): Whether to add a points at t = tspan[0] and t = tspan[-1] to the data returned. This makes trapezoidal integration less dependent on the time resolution. Default is False. + tspan_bg (iterable): Optional. A timespan defining when `item` is at its + baseline level. The average value of `item` in this interval will be + subtracted from the values returned. """ vseries = self[item] tseries = vseries.tseries @@ -499,15 +502,29 @@ def grab(self, item, tspan=None, include_endpoints=False): v = np.append(v, v_end) mask = np.logical_and(tspan[0] <= t, t <= tspan[-1]) t, v = t[mask], v[mask] + if tspan_bg: + t_bg, v_bg = self.grab(item, tspan=tspan_bg) + v = v - np.mean(v_bg) return t, v - def grab_for_t(self, item, t): - """Return a numpy array with the value of item interpolated to time t""" + def grab_for_t(self, item, t, tspan_bg=None): + """Return a numpy array with the value of item interpolated to time t + + Args: + item (str): The name of the value to grab + t (np array): The time vector to grab the value for + tspan_bg (iterable): Optional. A timespan defining when `item` is at its + baseline level. The average value of `item` in this interval will be + subtracted from what is returned. + """ vseries = self[item] tseries = vseries.tseries v_0 = vseries.data t_0 = tseries.data + tseries.tstamp - self.tstamp v = np.interp(t, t_0, v_0) + if tspan_bg: + t_bg, v_bg = self.grab(item, tspan=tspan_bg) + v = v - np.mean(v_bg) return v def integrate(self, item, tspan=None, ax=None): diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index 5c58d8d7..d55dd30a 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -217,7 +217,9 @@ def __init__( ): self.series_list.append( ConstantValue( - name=self.raw_current_names[0], unit_name="mA", value=0, + name=self.raw_current_names[0], + unit_name="mA", + value=0, ) ) self._populate_constants() # So that OCP currents are included as 0. @@ -232,7 +234,11 @@ def __init__( ] ): self.series_list.append( - ConstantValue(name=self.cycle_names[0], unit_name=None, value=0,) + ConstantValue( + name=self.cycle_names[0], + unit_name=None, + value=0, + ) ) self._populate_constants() # So that everything has a cycle number @@ -427,16 +433,28 @@ def calibrate(self, RE_vs_RHE=None, A_el=None, R_Ohm=None): self.correct_ohmic_drop(R_Ohm=R_Ohm) def calibrate_RE(self, RE_vs_RHE): - """Calibrate the reference electrode by providing `RE_vs_RHE` in [V].""" + """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 def normalize_current(self, A_el): - """Normalize current to electrod surface area by providing `A_el` in [cm^2].""" + """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 def correct_ohmic_drop(self, R_Ohm): - """Correct for ohmic drop by providing `R_Ohm` in [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): @@ -578,7 +596,10 @@ def _build_selector(self, sel_str=None): 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, + name=sel_str, + unit_name="", + data=selector, + tseries=self.potential.tseries, ) self[self.sel_str] = selector_series # TODO: Better cache'ing. This gets saved. diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index dfa32cf0..9e2e01ce 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -23,7 +23,10 @@ class MSMeasurement(Measurement): """Class implementing raw MS functionality""" extra_column_attrs = { - "ms_meaurements": {"mass_aliases", "signal_bgs",}, + "ms_meaurements": { + "mass_aliases", + "signal_bgs", + }, } def __init__( @@ -98,6 +101,7 @@ def grab_signal( t_bg (list): Timespan that corresponds to the background signal. If not given, no background is subtracted. removebackground (bool): Whether to remove a pre-set background if available + Defaults to False. (Note in grab_flux it defaults to True.) include_endpoints (bool): Whether to ensure tspan[0] and tspan[-1] are in t """ time, value = self.grab( @@ -138,7 +142,7 @@ def grab_flux( mol, tspan=None, tspan_bg=None, - removebackground=False, + removebackground=True, include_endpoints=False, ): """Return the flux of mol (calibrated signal) in [mol/s] @@ -149,6 +153,7 @@ def grab_flux( tspan_bg (list): Timespan that corresponds to the background signal. If not given, no background is subtracted. removebackground (bool): Whether to remove a pre-set background if available + Defaults to True. """ if isinstance(mol, str): if not self.calibration or mol not in self.calibration: @@ -173,7 +178,12 @@ def grab_flux( return x, n_dot def grab_flux_for_t( - self, mol, t, tspan_bg=None, removebackground=False, include_endpoints=False, + self, + mol, + t, + tspan_bg=None, + removebackground=False, + include_endpoints=False, ): """Return the flux of mol (calibrated signal) in [mol/s] for a given time vec @@ -197,16 +207,10 @@ def get_flux_series(self, mol, tspan=None): """Return a ValueSeries with the calibrated flux of mol during tspan""" t, n_dot = self.grab_flux(mol, tspan=tspan) tseries = TimeSeries( - name="n_dot_" + mol + "-t", - unit_name="s", - data=t, - tstamp=self.tstamp + name="n_dot_" + mol + "-t", unit_name="s", data=t, tstamp=self.tstamp ) vseries = ValueSeries( - name="n_dot_" + mol, - unit_name="mol/s", - data=n_dot, - tseries=tseries + name="n_dot_" + mol, unit_name="mol/s", data=n_dot, tseries=tseries ) return vseries @@ -275,7 +279,12 @@ class MSCalResult(Saveable): column_attrs = {"name", "mol", "mass", "cal_type", "F"} def __init__( - self, name=None, mol=None, mass=None, cal_type=None, F=None, + self, + name=None, + mol=None, + mass=None, + cal_type=None, + F=None, ): super().__init__() self.name = name or f"{mol} at {mass}" @@ -399,7 +408,13 @@ def calc_n_dot_0( return n_dot def gas_flux_calibration( - self, measurement, mol, mass, tspan=None, tspan_bg=None, ax=None, + self, + measurement, + mol, + mass, + tspan=None, + tspan_bg=None, + ax=None, ): """ Args: From 2580890528b8e324aef3bde88e68cae85e43dd07 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Wed, 24 Nov 2021 22:21:08 +0000 Subject: [PATCH 084/118] Import zilien .tsv without EC (Issues #17 and #20) --- .../reader_testers/test_zilien_reader.py | 30 +++++++++++-------- src/ixdat/readers/zilien.py | 29 +++++++++++++++--- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/development_scripts/reader_testers/test_zilien_reader.py b/development_scripts/reader_testers/test_zilien_reader.py index c5180f7e..9a42ae9f 100644 --- a/development_scripts/reader_testers/test_zilien_reader.py +++ b/development_scripts/reader_testers/test_zilien_reader.py @@ -1,18 +1,24 @@ -from ixdat.techniques import ECMeasurement +from pathlib import Path -path_to_file = ( - "../test_data/biologic_mpt_and_zilien_tsv/" - "2020-07-29 10_30_39 Pt_poly_cv_01_02_CVA_C01.mpt" -) +from ixdat import Measurement +from ixdat.techniques import MSMeasurement + +data_dir = Path(r"C:\Users\scott\Dropbox\ixdat_resources\test_data\zilien_with_ec") -m = ECMeasurement.read(path_to_file, reader="biologic", name="ec_tools_test",) +path_to_file = data_dir / "2021-02-01 17_44_12.tsv" -# ax = m.plot() +# This imports it with the EC data +ecms = Measurement.read(path_to_file, reader="zilien") +ecms.plot_measurement() -m.save() -i = m.id -del m +# This imports it without the EC data: +ms = MSMeasurement.read(path_to_file, reader="zilien") +ms.plot_measurement() # nice. one panel, no MS :) -m1 = ECMeasurement.get(i) +# This adds in the EC data from Biologic: -m1.plot() +ec = Measurement.read_set( + data_dir / "2021-02-01 17_44_12", reader="biologic", suffix=".mpt" +) +ecms_2 = ec + ms +ecms_2.plot_measurement() diff --git a/src/ixdat/readers/zilien.py b/src/ixdat/readers/zilien.py index f80e0473..f434af7d 100644 --- a/src/ixdat/readers/zilien.py +++ b/src/ixdat/readers/zilien.py @@ -3,7 +3,7 @@ import pandas as pd import numpy as np from ..data_series import DataSeries, TimeSeries, ValueSeries, Field -from ..techniques.ec_ms import ECMSMeasurement +from ..techniques.ec_ms import ECMSMeasurement, MSMeasurement, ECMeasurement from ..techniques.ms import MSSpectrum from .reading_tools import timestamp_string_to_tstamp, FLOAT_MATCH from .ec_ms_pkl import measurement_from_ec_ms_dataset @@ -23,12 +23,28 @@ def read(self, path_to_file, cls=None, name=None, **kwargs): from EC_MS import Zilien_Dataset ec_ms_dataset = Zilien_Dataset(path_to_file) + + if not issubclass(cls, ECMeasurement) and issubclass(cls, MSMeasurement): + # This is the case if the user specifically calls read() from an + # MSMeasurement + technique = "MS" + for col in ec_ms_dataset.data_cols.copy(): + # FIXME: EC_MS duplicates and renames Zilien's columns. + # Need a real Zilien reader! + if ec_ms_dataset.data["col_types"][col] == "EC" or col.startswith( + "pot" + ): + ec_ms_dataset.data["data_cols"].remove(col) + del ec_ms_dataset.data[col] + else: + technique = "EC-MS" + return measurement_from_ec_ms_dataset( ec_ms_dataset.data, cls=cls, name=name, reader=self, - technique="EC-MS", + technique=technique, **kwargs, ) @@ -116,7 +132,11 @@ def read(self, path_to_spectrum, cls=None, **kwargs): if path_to_spectrum: self.path_to_spectrum = Path(path_to_spectrum) cls = cls or MSSpectrum - df = pd.read_csv(path_to_spectrum, header=9, delimiter="\t",) + df = pd.read_csv( + path_to_spectrum, + header=9, + delimiter="\t", + ) x_name = "Mass [AMU]" y_name = "Current [A]" x = df[x_name].to_numpy() @@ -166,7 +186,8 @@ def read(self, path_to_spectrum, cls=None, **kwargs): ) ecms_measurement = Measurement.read( - reader="zilien", path_to_file=path_to_test_file, + reader="zilien", + path_to_file=path_to_test_file, ) ecms_measurement.plot_measurement() From 79e70fa666769f86c22e5ee3f5836629fee61e87 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Wed, 24 Nov 2021 22:40:05 +0000 Subject: [PATCH 085/118] fix plot_vs_potential axes (Issues #16 and #19) --- src/ixdat/plotters/ecms_plotter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ixdat/plotters/ecms_plotter.py b/src/ixdat/plotters/ecms_plotter.py index 160530b9..9bd6a369 100644 --- a/src/ixdat/plotters/ecms_plotter.py +++ b/src/ixdat/plotters/ecms_plotter.py @@ -201,7 +201,7 @@ def plot_vs_potential( if not axes: axes = self.new_two_panel_axes( - n_bottom=2, + n_bottom=1, n_top=(2 if (mass_lists or mol_lists) else 1), emphasis=emphasis, ) From 9433d5cf12d1d278c3d0b5639543f7f8d11d2d78 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Fri, 26 Nov 2021 09:05:53 +0000 Subject: [PATCH 086/118] update docs, ----- ixdat v0.1.6 ----- --- README.rst | 18 ++++++++----- docs/source/technique_docs/ec_ms.rst | 39 ++++++++++++++++++---------- docs/source/tutorials.rst | 9 ++++++- src/ixdat/__init__.py | 2 +- 4 files changed, 46 insertions(+), 22 deletions(-) diff --git a/README.rst b/README.rst index 0fbb3fb9..af839c22 100644 --- a/README.rst +++ b/README.rst @@ -102,15 +102,21 @@ Article repositories ``ixdat`` is shown in practice in a growing number of open repositories of data and analysis for academic publications: -- Tracking oxygen atoms in electrochemical CO oxidation - Part II: Lattice oxygen reactivity in oxides of Pt and Ir +- Soren B. Scott, et al. **Tracking oxygen atoms in electrochemical CO oxidation –Part I: Oxygen exchange via CO2 hydration**. `Electrochimica Acta, 374, 137842 `_, **2021**. - - Article: https://doi.org/10.1016/j.electacta.2021.137844 - - Repository: https://github.com/ScottSoren/pyCOox_public + Repository: https://github.com/ScottSoren/pyCOox_public -- Dynamic Interfacial Reaction Rates from Electrochemistry - Mass Spectrometry +- Soren B. Scott, et al. **Tracking oxygen atoms in electrochemical CO oxidation –Part II: Lattice oxygen reactivity in oxides of Pt and Ir**. `Electrochimica Acta, 374, 137844 `_, **2021**. - - Article: https://doi.org/10.1021/acs.analchem.1c00110 - - Repository: https://github.com/kkrempl/Dynamic-Interfacial-Reaction-Rates + Repository: https://github.com/ScottSoren/pyCOox_public + +- Kevin Krempl, et al. **Dynamic Interfacial Reaction Rates from Electrochemistry - Mass Spectrometry**. `Journal of Analytical Chemistry. 93, 7022-7028 `_, **2021** + + Repository: https://github.com/kkrempl/Dynamic-Interfacial-Reaction-Rates + +- Junheng Huang, et al. **Online Electrochemistry−Mass Spectrometry Evaluation of the Acidic Oxygen Evolution Reaction at Supported Catalysts**. `ACS Catal. 11, 12745-12753 `_, **2021** + + Repository: https://github.com/ScottSoren/Huang2021 Join us diff --git a/docs/source/technique_docs/ec_ms.rst b/docs/source/technique_docs/ec_ms.rst index f8484cd6..1aa6f78a 100644 --- a/docs/source/technique_docs/ec_ms.rst +++ b/docs/source/technique_docs/ec_ms.rst @@ -8,9 +8,8 @@ The main class for EC-MS data is the ECMSMeasurement. It comes with the :ref:`EC-MS plotter ` which makes EC-MS plots like this one: .. figure:: ../figures/ec_ms_annotated.svg - :width: 600 - - ``ECMSMeasurement.plot_measurement()``. Data from Trimarco, 2018. + :width: 600 + ``ECMSMeasurement.plot_measurement()``. Data from Trimarco, 2018. Other than that it doesn't have much but inherits from both ``ECMeasurement`` and ``MSMeasurement``. An ``ECMSMeasurement`` can be created either by adding an ``ECMeasurement`` and an ``MSMeasurement`` @@ -21,28 +20,40 @@ such as "zilien". based on an electrochemical cyclic voltammatry program that are implemented in ``CyclicVoltammogram`` (see :ref:`cyclic_voltammetry`). -Deconvolution, described in a publication under review, is implemented in the deconvolution module, -in a class inheriting from ``ECMSMeasurement``. +.. Deconvolution, described in a `recent publication `_, +is implemented in the deconvolution module, in a class inheriting from ``ECMSMeasurement``. -``ixdat`` will soon have all the functionality and more for EC-MS data and analysis as the +``ixdat`` has all the functionality and more for EC-MS data and analysis as the legacy `EC_MS `_ package. This includes the tools behind the EC-MS analysis and visualization in the puplications: -- Daniel B. Trimarco and Soren B. Scott, et al. **Enabling real-time detection of electrochemical desorption phenomena with sub-monolayer sensitivity**. `Electrochimica Acta, 2018 `_. +- Daniel B. Trimarco and Soren B. Scott, et al. **Enabling real-time detection of electrochemical desorption phenomena with sub-monolayer sensitivity**. `Electrochimica Acta, 268, 520-530 `_, **2018** + +- Claudie Roy, Bela Sebok, Soren B. Scott, et al. **Impact of nanoparticle size and lattice oxygen on water oxidation on NiFeOxHy**. `Nature Catalysis, 1(11), 820-829 `_, **2018** + +- Anna Winiwarter and Luca Silvioli, et al. **Towards an Atomistic Understanding of Electrocatalytic Partial Hydrocarbon Oxidation: Propene on Palladium**. `Energy and Environmental Science, 12, 1055-1067 `_, **2019** + +- Soren B. Scott and Albert Engstfeld, et al. **Anodic molecular hydrogen formation on Ru and Cu electrodes**. `Catalysis Science and Technology, 10, 6870-6878 `_, **2020** + +- Anna Winiwarter, et al. **CO as a Probe Molecule to Study Surface Adsorbates during Electrochemical Oxidation of Propene**. `ChemElectroChem, 8, 250-256 `_, **2021** + +``ixdat`` is used for the following EC-MS articles: + +- Soren B. Scott, et al. **Tracking oxygen atoms in electrochemical CO oxidation –Part I: Oxygen exchange via CO2 hydration**. `Electrochimica Acta, 374, 137842 `_, **2021**. -- Claudie Roy, Bela Sebok, Soren B. Scott, et al. **Impact of nanoparticle size and lattice oxygen on water oxidation on NiFeOxHy**. `Nature Catalysis, 2018 `_. + Repository: https://github.com/ScottSoren/pyCOox_public -- Anna Winiwarter and Luca Silvioli, et al. **Towards an Atomistic Understanding of Electrocatalytic Partial Hydrocarbon Oxidation: Propene on Palladium**. `Energy and Environmental Science, 2019 `_. +- Soren B. Scott, et al. **Tracking oxygen atoms in electrochemical CO oxidation –Part II: Lattice oxygen reactivity in oxides of Pt and Ir**. `Electrochimica Acta, 374, 137844 `_, **2021**. -- Soren B. Scott and Albert Engstfeld, et al. **Anodic molecular hydrogen formation on Ru and Cu electrodes**. `Catalysis Science & Technology, 2020 `_. + Repository: https://github.com/ScottSoren/pyCOox_public -- Anna Winiwarter, et al. **CO as a Probe Molecule to Study Surface Adsorbates during Electrochemical Oxidation of Propene**. `ChemElectroChem, 2021 `_. +- Kevin Krempl, et al. **Dynamic Interfacial Reaction Rates from Electrochemistry - Mass Spectrometry**. `Journal of Analytical Chemistry. 93, 7022-7028 `_, **2021** -``ixdat`` is used for the following articles: + Repository: https://github.com/kkrempl/Dynamic-Interfacial-Reaction-Rates -- Soren B. Scott, et al. **Tracking oxygen atoms in electrochemical CO oxidation –Part I: Oxygen exchange via CO2 hydration**. `Electrochimica Acta, 2021a `_. +- Junheng Huang, et al. **Online Electrochemistry−Mass Spectrometry Evaluation of the Acidic Oxygen Evolution Reaction at Supported Catalysts**. `ACS Catal. 11, 12745-12753 `_, **2021** -- Soren B. Scott, et al. **Tracking oxygen atoms in electrochemical CO oxidation –Part II: Lattice oxygen reactivity in oxides of Pt and Ir**. `Electrochimica Acta, 2021b `_. + Repository: https://github.com/ScottSoren/Huang2021 The ``ec_ms`` module diff --git a/docs/source/tutorials.rst b/docs/source/tutorials.rst index 7469a798..9af51e43 100644 --- a/docs/source/tutorials.rst +++ b/docs/source/tutorials.rst @@ -61,12 +61,19 @@ Article repositories Calibrating EC-MS data ********************** -See these two examples, respectively, for making and using an ixdat EC-MS calibration: +See these two examples, respectively, for making and using an ixdat EC-MS calibration (here with isotope-labeled data): - https://github.com/ScottSoren/pyCOox_public/blob/main/paper_I_fig_S1/paper_I_fig_S1.py - https://github.com/ScottSoren/pyCOox_public/blob/main/paper_I_fig_2/paper_I_fig_2.py +EC-MS data analysis +******************* + +This article has examples of analyzing and manually plotting data imported by ixdat + +https://github.com/ScottSoren/Huang2021 + Development scripts ------------------- diff --git a/src/ixdat/__init__.py b/src/ixdat/__init__.py index cef1ea29..713c332e 100644 --- a/src/ixdat/__init__.py +++ b/src/ixdat/__init__.py @@ -1,6 +1,6 @@ """initialize ixdat, giving top-level access to a few of the important structures """ -__version__ = "0.1.5" +__version__ = "0.1.6" __title__ = "ixdat" __description__ = "The in-situ experimental data tool" __url__ = "https://github.com/ixdat/ixdat" From c387249900b199ab38af73061469fd7f327f56be Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Thu, 2 Dec 2021 14:43:11 +0000 Subject: [PATCH 087/118] black format, pass checksgit add .! --- src/ixdat/db.py | 7 +++++-- src/ixdat/exporters/ecms_exporter.py | 15 +++++++------ src/ixdat/measurements.py | 4 ++-- src/ixdat/plotters/ecms_plotter.py | 2 +- src/ixdat/plotters/sec_plotter.py | 21 ++++++++++++++----- src/ixdat/plotters/spectrum_plotter.py | 2 +- src/ixdat/readers/biologic.py | 9 ++++---- src/ixdat/readers/cinfdata.py | 4 ++-- src/ixdat/readers/msrh_sec.py | 3 +-- src/ixdat/readers/reading_tools.py | 4 +++- src/ixdat/readers/rgasoft.py | 8 ------- src/ixdat/readers/zilien.py | 1 - src/ixdat/spectra.py | 2 +- src/ixdat/techniques/cv.py | 9 ++++++-- src/ixdat/techniques/deconvolution.py | 5 ++++- src/ixdat/techniques/ec_ms.py | 10 +++++++-- .../techniques/spectroelectrochemistry.py | 21 ++++++++++++++----- tasks.py | 5 +++-- 18 files changed, 82 insertions(+), 50 deletions(-) diff --git a/src/ixdat/db.py b/src/ixdat/db.py index 85cb41dc..85490933 100644 --- a/src/ixdat/db.py +++ b/src/ixdat/db.py @@ -331,8 +331,11 @@ def __eq__(self, other): return False if self.extra_linkers: linker_id_names = [ - id_name for (linker_table_name, (linked_table_name, id_name)) - in self.extra_linkers.items() + id_name + for ( + linker_table_name, + (linked_table_name, id_name), + ) in self.extra_linkers.items() ] # FIXME: This will be made much simpler with coming metaprogramming else: linker_id_names = [] diff --git a/src/ixdat/exporters/ecms_exporter.py b/src/ixdat/exporters/ecms_exporter.py index 98693f5c..b24f2bd9 100644 --- a/src/ixdat/exporters/ecms_exporter.py +++ b/src/ixdat/exporters/ecms_exporter.py @@ -16,13 +16,13 @@ def default_v_list(self): return v_list def export( - self, - path_to_file=None, - measurement=None, - v_list=None, - tspan=None, - mass_list=None, - mol_list=None, + self, + path_to_file=None, + measurement=None, + v_list=None, + tspan=None, + mass_list=None, + mol_list=None, ): if not v_list: if mass_list: @@ -34,4 +34,3 @@ def export( if mol_list: v_list += [f"n_dot_{mol}" for mol in mol_list] return super().export(path_to_file, measurement, v_list, tspan) - diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index 643b4d93..b833d390 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -22,8 +22,8 @@ time_shifted, get_tspans_from_mask, ) -from .samples import Sample -from .lablogs import LabLog +from .projects.samples import Sample +from .projects.lablogs import LabLog from .exporters.csv_exporter import CSVExporter from .plotters.value_plotter import ValuePlotter from .exceptions import BuildError, SeriesNotFoundError diff --git a/src/ixdat/plotters/ecms_plotter.py b/src/ixdat/plotters/ecms_plotter.py index 9bd6a369..ba0d6e3d 100644 --- a/src/ixdat/plotters/ecms_plotter.py +++ b/src/ixdat/plotters/ecms_plotter.py @@ -166,7 +166,7 @@ def plot_vs_potential( - variable subplot sizing (emphasizing EC or MS) - plotting of calibrated data (mol_list instead of mass_list) - units! - + Args: measurement (ECMSMeasurement): defaults to the measurement to which the plotter is bound (self.measurement) diff --git a/src/ixdat/plotters/sec_plotter.py b/src/ixdat/plotters/sec_plotter.py index fc0985ec..78a7a83d 100644 --- a/src/ixdat/plotters/sec_plotter.py +++ b/src/ixdat/plotters/sec_plotter.py @@ -62,9 +62,16 @@ def plot_measurement( measurement = measurement or self.measurement if not axes: - axes = self.new_two_panel_axes(n_bottom=2, n_top=1, emphasis="top",) + axes = self.new_two_panel_axes( + n_bottom=2, + n_top=1, + emphasis="top", + ) self.ec_plotter.plot_measurement( - measurement=measurement, axes=[axes[1], axes[2]], tspan=tspan, **kwargs, + measurement=measurement, + axes=[axes[1], axes[2]], + tspan=tspan, + **kwargs, ) dOD_series = measurement.calc_dOD(V_ref=V_ref, t_ref=t_ref) @@ -168,7 +175,11 @@ def plot_vs_potential( measurement = measurement or self.measurement if not axes: - axes = self.new_two_panel_axes(n_bottom=1, n_top=1, emphasis="top",) + axes = self.new_two_panel_axes( + n_bottom=1, + n_top=1, + emphasis="top", + ) self.ec_plotter.plot_vs_potential( measurement=measurement, @@ -231,7 +242,7 @@ def plot_wavelengths( t, y = measurement.grab(wl_str, tspan=tspan) axes[0].plot(t, y, color=cmap(norm(x)), label=wl_str) axes[0].legend() - axes[0].set_ylabel("$\Delta$O.D.") + axes[0].set_ylabel(r"$\Delta$O.D.") self.ec_plotter.plot_measurement( measurement=measurement, axes=axes[1:], tspan=tspan, **kwargs @@ -277,7 +288,7 @@ def plot_wavelengths_vs_potential( v = measurement.v axes[0].plot(v, y, color=cmap(norm(x)), label=wl_str) axes[0].legend() - axes[0].set_ylabel("$\Delta$O.D.") + axes[0].set_ylabel(r"$\Delta$O.D.") self.ec_plotter.plot_vs_potential( measurement=measurement, ax=axes[1], tspan=tspan, **kwargs diff --git a/src/ixdat/plotters/spectrum_plotter.py b/src/ixdat/plotters/spectrum_plotter.py index 57ba263b..63abe240 100644 --- a/src/ixdat/plotters/spectrum_plotter.py +++ b/src/ixdat/plotters/spectrum_plotter.py @@ -157,7 +157,7 @@ def heat_plot_vs( use_gridspec=True, anchor=(0.75, 0), ) - cb.set_label("$\Delta$ opdical density") + cb.set_label("intensity") return ax def plot_waterfall( diff --git a/src/ixdat/readers/biologic.py b/src/ixdat/readers/biologic.py index 8bb4e1b3..07acefd9 100644 --- a/src/ixdat/readers/biologic.py +++ b/src/ixdat/readers/biologic.py @@ -3,15 +3,15 @@ Demonstrated/tested at the bottom under `if __name__ == "__main__":` """ -from pathlib import Path import re -import time +from pathlib import Path + import numpy as np from . import TECHNIQUE_CLASSES +from .reading_tools import timestamp_string_to_tstamp from ..data_series import TimeSeries, ValueSeries, ConstantValue from ..exceptions import ReadError -from .reading_tools import timestamp_string_to_tstamp ECMeasurement = TECHNIQUE_CLASSES["EC"] delim = "\t" @@ -107,7 +107,9 @@ def read(self, path_to_file, name=None, cls=ECMeasurement, **kwargs): any case. **kwargs (dict): Key-word arguments are passed to cls.__init__ """ + path_to_file = Path(path_to_file) if path_to_file else self.path_to_file + if self.file_has_been_read: print( f"This {self.__class__.__name__} has already read {self.path_to_file}." @@ -324,7 +326,6 @@ def get_column_unit(column_name): Script path = ... """ - from pathlib import Path from matplotlib import pyplot as plt from ixdat.measurements import Measurement diff --git a/src/ixdat/readers/cinfdata.py b/src/ixdat/readers/cinfdata.py index 7bc2463d..eda62b3a 100644 --- a/src/ixdat/readers/cinfdata.py +++ b/src/ixdat/readers/cinfdata.py @@ -152,7 +152,7 @@ def process_header_line(self, line): self.place_in_file = "post_header" elif "Recorded at" in line: for s in line.split(self.delim): - if not "Recorded at" in s: + if "Recorded at" not in s: self.tstamp_list.append( timestamp_string_to_tstamp( s.strip()[1:-1], # remove edge whitespace and quotes. @@ -171,7 +171,7 @@ def process_column_line(self, line): if col.endswith("-y"): name = col[:-2] tcol = f"{name}-x" - if not tcol in self.column_names: + if tcol not in self.column_names: print(f"Warning! No timecol for {col}. Expected {tcol}. Ignoring.") continue self.t_and_v_cols[name] = (tcol, col) diff --git a/src/ixdat/readers/msrh_sec.py b/src/ixdat/readers/msrh_sec.py index 33cdb7bd..794a6e29 100644 --- a/src/ixdat/readers/msrh_sec.py +++ b/src/ixdat/readers/msrh_sec.py @@ -1,4 +1,4 @@ -from pathlib import Path +from pathlib import Path # noqa import numpy as np import pandas as pd from .reading_tools import prompt_for_tstamp @@ -47,7 +47,6 @@ def read( cls (Measurement subclass): The class of measurement to return. Defaults to SpectroECMeasurement. """ - # us pandas to load the data from the csv files into dataframes: sec_df = pd.read_csv(path_to_file) # ^ Note the first row, containing potential, will become the keys. The first diff --git a/src/ixdat/readers/reading_tools.py b/src/ixdat/readers/reading_tools.py index 6d29e751..7c9f25df 100644 --- a/src/ixdat/readers/reading_tools.py +++ b/src/ixdat/readers/reading_tools.py @@ -14,7 +14,9 @@ def timestamp_string_to_tstamp( - timestamp_string, form=None, forms=(STANDARD_TIMESTAMP_FORM,), + timestamp_string, + form=None, + forms=(STANDARD_TIMESTAMP_FORM,), ): """Return the unix timestamp as a float by parsing timestamp_string diff --git a/src/ixdat/readers/rgasoft.py b/src/ixdat/readers/rgasoft.py index 33b6e6a5..1558360e 100644 --- a/src/ixdat/readers/rgasoft.py +++ b/src/ixdat/readers/rgasoft.py @@ -1,7 +1,6 @@ """A reader for text exports from the potentiostat software of CH Instruments""" from EC_MS import Dataset -from .reading_tools import timestamp_string_to_tstamp from .ec_ms_pkl import measurement_from_ec_ms_dataset from ..techniques import MSMeasurement @@ -19,13 +18,6 @@ def read(self, path_to_file, cls=None): cls (Measurement subclass): The class to return. Defaults to ECMeasuremnt """ - # with open(path_to_file, "r") as f: - # timestamp_string = f.readline().strip() - # tstamp = timestamp_string_to_tstamp( - # timestamp_string, - # form="%b %d, %Y %I:%M:%S %p", # like "Mar 05, 2020 09:50:34 AM" - # ) # ^ For later. EC_MS actually gets this right. - self.path_to_file = path_to_file cls = cls if (cls and not issubclass(MSMeasurement, cls)) else MSMeasurement ec_ms_dataset = Dataset( diff --git a/src/ixdat/readers/zilien.py b/src/ixdat/readers/zilien.py index f434af7d..9480af58 100644 --- a/src/ixdat/readers/zilien.py +++ b/src/ixdat/readers/zilien.py @@ -1,4 +1,3 @@ -from pathlib import Path import re import pandas as pd import numpy as np diff --git a/src/ixdat/spectra.py b/src/ixdat/spectra.py index 9636e29a..5b36ae29 100644 --- a/src/ixdat/spectra.py +++ b/src/ixdat/spectra.py @@ -257,7 +257,7 @@ def __add__(self, other): class SpectrumSeries(Spectrum): def __init__(self, *args, **kwargs): - if not "technique" in kwargs: + if "technique" not in kwargs: kwargs["technique"] = "spectra" super().__init__(*args, **kwargs) diff --git a/src/ixdat/techniques/cv.py b/src/ixdat/techniques/cv.py index d613edbf..8f741467 100644 --- a/src/ixdat/techniques/cv.py +++ b/src/ixdat/techniques/cv.py @@ -123,7 +123,12 @@ def select_sweep(self, vspan, t_i=None): 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,) + tspan = tspan_passing_through( + t=self.t, + v=self.v, + vspan=vspan, + t_i=t_i, + ) return self.cut(tspan=tspan) def integrate(self, item, tspan=None, vspan=None, ax=None): @@ -197,7 +202,7 @@ def calc_capacitance(self, vspan): 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] + cap = 1 / 2 * (I_1 / v_scan_1 + I_2 / v_scan_2) # [A] / [V/s] = [C/V] = [F] return cap def diff_with(self, other, v_list=None, cls=None, v_scan_res=0.001, res_points=10): diff --git a/src/ixdat/techniques/deconvolution.py b/src/ixdat/techniques/deconvolution.py index 74aded7a..ebd4e7c8 100644 --- a/src/ixdat/techniques/deconvolution.py +++ b/src/ixdat/techniques/deconvolution.py @@ -97,7 +97,10 @@ class Kernel: # TODO: Make class inherit from Measurement, add properties to store kernel # TODO: Reference equations to paper. def __init__( - self, parameters={}, MS_data=None, EC_data=None, + self, + parameters={}, + MS_data=None, + EC_data=None, ): """Initializes a Kernel object either in functional form by defining the mass transport parameters or in the measured form by passing of EC-MS diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index 21628896..c7be40e0 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -120,7 +120,11 @@ def ecms_calibration(self, mol, mass, n_el, tspan, tspan_bg=None): n = Q / (n_el * FARADAY_CONSTANT) F = Y / n cal = MSCalResult( - name=f"{mol}_{mass}", mol=mol, mass=mass, cal_type="ecms_calibration", F=F, + name=f"{mol}_{mass}", + mol=mol, + mass=mass, + cal_type="ecms_calibration", + F=F, ) return cal @@ -158,7 +162,8 @@ def ecms_calibration_curve( for tspan in tspan_list: Y = self.integrate_signal(mass, tspan=tspan, tspan_bg=tspan_bg, ax=axis_ms) # FIXME: plotting current by giving integrate() an axis doesn't work great. - Q = self.integrate("raw current / [mA]", tspan=tspan) * 1e-3 + Q = self.integrate("raw current / [mA]", tspan=tspan, axis=axis_current) + Q *= 1e-3 # mC --> [C] n = Q / (n_el * FARADAY_CONSTANT) Y_list.append(Y) n_list.append(n) @@ -278,6 +283,7 @@ class ECMSCalibration(Saveable): column_attrs = {"name", "date", "setup", "ms_cal_results", "RE_vs_RHE", "A_el", "L"} # FIXME: Not given a table_name as it can't save to the database without # MSCalResult's being json-seriealizeable. Exporting and reading works, though :D + def __init__( self, name=None, diff --git a/src/ixdat/techniques/spectroelectrochemistry.py b/src/ixdat/techniques/spectroelectrochemistry.py index 2b5d43ba..0c4c00cb 100644 --- a/src/ixdat/techniques/spectroelectrochemistry.py +++ b/src/ixdat/techniques/spectroelectrochemistry.py @@ -26,7 +26,10 @@ def reference_spectrum(self): return self._reference_spectrum def set_reference_spectrum( - self, spectrum=None, t_ref=None, V_ref=None, + self, + spectrum=None, + t_ref=None, + V_ref=None, ): """Set the spectrum used as the reference when calculating dOD. @@ -57,7 +60,9 @@ def spectra(self): def spectrum_series(self): """The SpectrumSeries that is the spectra of the SEC Measurement""" return SpectrumSeries.from_field( - self.spectra, tstamp=self.tstamp, name=self.name + " spectra", + self.spectra, + tstamp=self.tstamp, + name=self.name + " spectra", ) @property @@ -106,7 +111,7 @@ def calc_dOD(self, V_ref=None, t_ref=None, index_ref=None): ref_spec = self.reference_spectrum dOD = -np.log10(counts / ref_spec.y) dOD_series = Field( - name="$\Delta$ O.D.", + name=r"$\Delta$ O.D.", unit_name="", axes_series=self.spectra.axes_series, data=dOD, @@ -163,7 +168,13 @@ def get_spectrum(self, V=None, t=None, index=None, name=None): return Spectrum.from_field(field, tstamp=self.tstamp) def get_dOD_spectrum( - self, V=None, t=None, index=None, V_ref=None, t_ref=None, index_ref=None, + self, + V=None, + t=None, + index=None, + V_ref=None, + t_ref=None, + index_ref=None, ): """Return the delta optical density Spectrum given a point and reference point. @@ -187,7 +198,7 @@ def get_dOD_spectrum( spectrum = self.get_spectrum(V=V, t=t, index=index) field = Field( data=-np.log10(spectrum.y / spectrum_ref.y), - name="$\Delta$ OD", + name=r"$\Delta$ OD", unit_name="", axes_series=[self.wavelength], ) diff --git a/tasks.py b/tasks.py index 5e620b23..9a214dc7 100644 --- a/tasks.py +++ b/tasks.py @@ -32,7 +32,7 @@ def flake8(context): """ print("# flake8") - return context.run("flake8").return_code + return context.run("flake8 src tests").return_code @task(aliases=["test", "tests"]) @@ -43,7 +43,8 @@ def pytest(context): """ print("# pytest") - return context.run("pytest").return_code + return context.run("pytest tests").return_code + # TODO: This now only works if we call it from project root. @task(aliases=["QA", "qa", "check"]) From 802b8648cb11ab3eee67bd4867e7dceab455ec22 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Thu, 2 Dec 2021 15:21:13 +0000 Subject: [PATCH 088/118] debug for test_zilien_reader --- development_scripts/append_ec_files.py | 2 +- development_scripts/functional_test.py | 6 +-- .../reader_testers/test_msrh_sec_reader.py | 12 ++++- .../test_zilien_spectrum_reader.py | 5 +- src/ixdat/measurements.py | 8 +-- src/ixdat/plotters/ecms_plotter.py | 12 ++--- src/ixdat/readers/biologic.py | 3 ++ src/ixdat/readers/zilien.py | 10 ++++ src/ixdat/spectra.py | 26 ++-------- src/ixdat/techniques/cv.py | 12 +---- src/ixdat/techniques/ec_ms.py | 49 ++----------------- src/ixdat/techniques/ms.py | 8 +-- .../techniques/spectroelectrochemistry.py | 20 ++------ 13 files changed, 54 insertions(+), 119 deletions(-) diff --git a/development_scripts/append_ec_files.py b/development_scripts/append_ec_files.py index b54c21e3..c947ea6b 100644 --- a/development_scripts/append_ec_files.py +++ b/development_scripts/append_ec_files.py @@ -74,7 +74,7 @@ cv_selection = cv[10:16] cv_selection.plot_measurement(j_name="cycle") -cv_selection.redefine_cycle(start_potential=0.4, redox=1) +cv_selection.redefine_cycle(start_potential=0.45, redox=1) cv_selection.plot_measurement(j_name="cycle") ax = cv_selection[1].plot(label="cycle 1") diff --git a/development_scripts/functional_test.py b/development_scripts/functional_test.py index ff407313..28daf901 100644 --- a/development_scripts/functional_test.py +++ b/development_scripts/functional_test.py @@ -28,7 +28,7 @@ assert np.isclose( ec_measurement.v[0] - ec_measurement["raw_potential"].data[0], - ec_measurement.RE_vs_RHE + ec_measurement.RE_vs_RHE, ) # To make it complex, we first select a couple cycles, this time by converting @@ -39,9 +39,7 @@ # Check that the calibration survived all that: assert cvs_1_plus_2.RE_vs_RHE == ec_measurement.RE_vs_RHE # Check that the main time variable, that of potential, wasn't corrupted: -assert len(cvs_1_plus_2.grab("potential")[0]) == len( - cvs_1_plus_2["time/s"].data -) +assert len(cvs_1_plus_2.grab("potential")[0]) == len(cvs_1_plus_2["time/s"].data) # Check that the selector is still available and works with the main time var: assert len(cvs_1_plus_2.selector.data) == len(cvs_1_plus_2.t) diff --git a/development_scripts/reader_testers/test_msrh_sec_reader.py b/development_scripts/reader_testers/test_msrh_sec_reader.py index b3f91d6c..f5727ccc 100644 --- a/development_scripts/reader_testers/test_msrh_sec_reader.py +++ b/development_scripts/reader_testers/test_msrh_sec_reader.py @@ -35,9 +35,17 @@ sec_reloaded.plot_vs_potential(cmap_name="jet") -axes = sec_meas.plot_measurement(V_ref=0.4, cmap_name="jet", make_colorbar=True,) +axes = sec_meas.plot_measurement( + V_ref=0.4, + cmap_name="jet", + make_colorbar=True, +) -ax = sec_meas.plot_waterfall(V_ref=0.4, cmap_name="jet", make_colorbar=True,) +ax = sec_meas.plot_waterfall( + V_ref=0.4, + cmap_name="jet", + make_colorbar=True, +) ax.get_figure().savefig("sec_waterfall.png") axes2 = sec_meas.plot_vs_potential(V_ref=0.66, cmap_name="jet", make_colorbar=False) diff --git a/development_scripts/reader_testers/test_zilien_spectrum_reader.py b/development_scripts/reader_testers/test_zilien_spectrum_reader.py index 53889b37..77f7dbd6 100644 --- a/development_scripts/reader_testers/test_zilien_spectrum_reader.py +++ b/development_scripts/reader_testers/test_zilien_spectrum_reader.py @@ -6,7 +6,10 @@ / "mass scan started at measurement time 0001700.tsv" ) -spec = Spectrum.read(path_to_file, reader="zilien_spec",) +spec = Spectrum.read( + path_to_file, + reader="zilien_spec", +) spec.plot(color="k") diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index b833d390..8d1fb79a 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -211,7 +211,7 @@ def read(cls, path_to_file, reader, **kwargs): from .readers import READER_CLASSES reader = READER_CLASSES[reader]() - obj = reader.read(path_to_file, **kwargs) # TODO: take cls as kwarg + obj = reader.read(path_to_file, cls=cls, **kwargs) # TODO: take cls as kwarg if obj.__class__.essential_series_names: for series_name in obj.__class__.essential_series_names: @@ -266,9 +266,9 @@ def read_set( cls.read(f, reader=reader, **kwargs) for f in file_list ] - if base_name and "name" not in kwargs: - kwargs["name"] = base_name - measurement = cls.from_component_measurements(component_measurements, **kwargs) + measurement = None + for meas in component_measurements: + measurement = measurement + meas if measurement else meas return measurement @classmethod diff --git a/src/ixdat/plotters/ecms_plotter.py b/src/ixdat/plotters/ecms_plotter.py index ba0d6e3d..cee379d1 100644 --- a/src/ixdat/plotters/ecms_plotter.py +++ b/src/ixdat/plotters/ecms_plotter.py @@ -25,10 +25,10 @@ def plot_measurement( tspan_bg=None, removebackground=None, unit=None, - V_str=None, + V_str=None, # TODO: Depreciate, replace with v_name, j_name J_str=None, V_color="k", - J_color="r", + J_color="r", # TODO: Depreciate, replace with v_name, j_name logplot=None, legend=True, emphasis="top", @@ -107,10 +107,10 @@ def plot_measurement( measurement=measurement, axes=[axes[1], axes[2]], tspan=tspan, - V_str=V_str, - J_str=J_str, - V_color=V_color, - J_color=J_color, + v_name=V_str, + j_name=J_str, + v_color=V_color, + j_color=J_color, **kwargs, ) if ( diff --git a/src/ixdat/readers/biologic.py b/src/ixdat/readers/biologic.py index 07acefd9..f6069999 100644 --- a/src/ixdat/readers/biologic.py +++ b/src/ixdat/readers/biologic.py @@ -110,6 +110,9 @@ def read(self, path_to_file, name=None, cls=ECMeasurement, **kwargs): path_to_file = Path(path_to_file) if path_to_file else self.path_to_file + if issubclass(ECMeasurement, cls): + cls = ECMeasurement + if self.file_has_been_read: print( f"This {self.__class__.__name__} has already read {self.path_to_file}." diff --git a/src/ixdat/readers/zilien.py b/src/ixdat/readers/zilien.py index 9480af58..b1c525d0 100644 --- a/src/ixdat/readers/zilien.py +++ b/src/ixdat/readers/zilien.py @@ -9,6 +9,15 @@ ZILIEN_TIMESTAMP_FORM = "%Y-%m-%d %H_%M_%S" # like 2021-03-15 18_50_10 +ZILIEN_LEGACY_ALIASES = { + # TODO: These should change to what Zilien calls them. Right now the alias's + # reflect the way the lagacy EC_MS code renames essential series + "t": ["time/s"], + "raw_potential": ["Ewe/V", "/V"], + "raw_current": ["I/mA", "/mA"], + "cycle": ["cycle number"], +} + class ZilienTSVReader: """Class for reading files saved by Spectro Inlets' Zilien software""" @@ -44,6 +53,7 @@ def read(self, path_to_file, cls=None, name=None, **kwargs): name=name, reader=self, technique=technique, + aliases=ZILIEN_LEGACY_ALIASES, **kwargs, ) diff --git a/src/ixdat/spectra.py b/src/ixdat/spectra.py index 5b36ae29..66f12fe2 100644 --- a/src/ixdat/spectra.py +++ b/src/ixdat/spectra.py @@ -2,6 +2,7 @@ from .db import Saveable, PlaceHolderObject from .data_series import DataSeries, TimeSeries, Field from .exceptions import BuildError +from .plotters.spectrum_plotter import SpectrumPlotter, SpectrumSeriesPlotter class Spectrum(Saveable): @@ -66,21 +67,10 @@ def __init__( self.reader = reader self._field = field or PlaceHolderObject(field_id, cls=Field) - self._plotter = None + self.plotter = SpectrumPlotter # defining this method here gets it the right docstrings :D self.plot = self.plotter.plot - @property - def plotter(self): - """The default plotter for Measurement is ValuePlotter.""" - if not self._plotter: - from .plotters.spectrum_plotter import SpectrumPlotter - - # FIXME: I had to import here to avoid running into circular import issues - - self._plotter = SpectrumPlotter(spectrum=self) - return self._plotter - @classmethod def read(cls, path_to_file, reader, **kwargs): """Return a Measurement object from parsing a file with the specified reader @@ -260,6 +250,7 @@ def __init__(self, *args, **kwargs): if "technique" not in kwargs: kwargs["technique"] = "spectra" super().__init__(*args, **kwargs) + self.plotter = SpectrumSeriesPlotter @property def yseries(self): @@ -321,14 +312,3 @@ def __getitem__(self, key): def y_average(self): """The y-data of the average spectrum""" return np.mean(self.y, axis=0) - - @property - def plotter(self): - """The default plotter for Measurement is ValuePlotter.""" - if not self._plotter: - from .plotters.spectrum_plotter import SpectrumSeriesPlotter - - # FIXME: I had to import here to avoid running into circular import issues - - self._plotter = SpectrumSeriesPlotter(spectrum_series=self) - return self._plotter diff --git a/src/ixdat/techniques/cv.py b/src/ixdat/techniques/cv.py index 8f741467..010a7539 100644 --- a/src/ixdat/techniques/cv.py +++ b/src/ixdat/techniques/cv.py @@ -7,6 +7,7 @@ calc_sharp_v_scan, find_signed_sections, ) +from ..plotters.ec_plotter import CVDiffPlotter class CyclicVoltammagram(ECMeasurement): @@ -324,13 +325,4 @@ 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 CyclicVoltammagramDiff is CVDiffPlotter""" - if not self._plotter: - from ..plotters.ec_plotter import CVDiffPlotter - - self._plotter = CVDiffPlotter(measurement=self) - - return self._plotter + self.plotter = CVDiffPlotter(measurement=self) diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index c7be40e0..000001d4 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -5,6 +5,7 @@ from .ms import MSMeasurement, MSCalResult from .cv import CyclicVoltammagram from ..exporters.ecms_exporter import ECMSExporter +from ..plotters.ecms_plotter import ECMSPlotter from ..plotters.ms_plotter import STANDARD_COLORS from ..db import Saveable # FIXME: doesn't belong here. import json # FIXME: doesn't belong here. @@ -20,13 +21,10 @@ class ECMSMeasurement(ECMeasurement, MSMeasurement): "mass_aliases", "signal_bgs", "ec_technique", - "RE_vs_RHE", - "R_Ohm", - "raw_potential_names", - "A_el", - "raw_current_names", }, } + default_plotter = ECMSPlotter + default_exporter = ECMSExporter def __init__(self, **kwargs): if "calibration" in kwargs and kwargs["calibration"]: @@ -54,23 +52,6 @@ def __init__(self, **kwargs): ECMeasurement.__init__(self, **ec_kwargs) MSMeasurement.__init__(self, **ms_kwargs) - @property - def plotter(self): - """The default plotter for ECMSMeasurement is ECMSPlotter""" - if not self._plotter: - from ..plotters.ecms_plotter import ECMSPlotter - - self._plotter = ECMSPlotter(measurement=self) - - return self._plotter - - @property - def exporter(self): - """The default plotter for ECMSMeasurement is ECMSExporter""" - if not self._exporter: - self._exporter = ECMSExporter(measurement=self) - return self._exporter - def as_dict(self): self_as_dict = super().as_dict() @@ -209,13 +190,10 @@ class ECMSCyclicVoltammogram(CyclicVoltammagram, MSMeasurement): "mass_aliases", "signal_bgs", "ec_technique", - "RE_vs_RHE", - "R_Ohm", - "raw_potential_names", - "A_el", - "raw_current_names", }, } + default_plotter = ECMSPlotter + default_exporter = ECMSExporter def __init__(self, **kwargs): """FIXME: Passing the right key-word arguments on is a mess""" @@ -233,23 +211,6 @@ def __init__(self, **kwargs): # FIXME: only necessary because an ECMSCalibration is not seriealizeable. self.calibration = kwargs.get("calibration", None) - @property - def plotter(self): - """The default plotter for ECMSCyclicVoltammogram is ECMSPlotter""" - if not self._plotter: - from ..plotters.ecms_plotter import ECMSPlotter - - self._plotter = ECMSPlotter(measurement=self) - - return self._plotter - - @property - def exporter(self): - """The default plotter for ECMSCyclicVoltammogram is ECMSExporter""" - if not self._exporter: - self._exporter = ECMSExporter(measurement=self) - return self._exporter - def as_dict(self): self_as_dict = super().as_dict() diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index 9e2e01ce..0bec1b19 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -28,6 +28,7 @@ class MSMeasurement(Measurement): "signal_bgs", }, } + default_plotter = MSPlotter def __init__( self, @@ -261,13 +262,6 @@ def as_mass(self, item): except StopIteration: raise TypeError(f"{self} does not recognize '{item}' as a mass.") - @property - def plotter(self): - """The default plotter for MSMeasurement is MSPlotter""" - if not self._plotter: - self._plotter = MSPlotter(measurement=self) - return self._plotter - class MSCalResult(Saveable): """A class for a mass spec calibration result. diff --git a/src/ixdat/techniques/spectroelectrochemistry.py b/src/ixdat/techniques/spectroelectrochemistry.py index 0c4c00cb..d5a768d3 100644 --- a/src/ixdat/techniques/spectroelectrochemistry.py +++ b/src/ixdat/techniques/spectroelectrochemistry.py @@ -1,4 +1,5 @@ from .ec import ECMeasurement +from ..plotters.sec_plotter import SECPlotter from ..spectra import Spectrum from ..data_series import Field, ValueSeries import numpy as np @@ -17,6 +18,8 @@ def __init__(self, *args, **kwargs): self.plot_wavelengths = self.plotter.plot_wavelengths self.plot_wavelengths_vs_potential = self.plotter.plot_wavelengths_vs_potential self.technique = "S-EC" + self.plotter = SECPlotter(measurement=self) + self.exporter = SECExporter(measurement=self) @property def reference_spectrum(self): @@ -75,23 +78,6 @@ def wl(self): """A numpy array with the wavelengths in [nm] for the SEC spectra""" return self.wavelength.data - @property - def plotter(self): - """The default plotter for SpectroECMeasurement is SECPlotter""" - if not self._plotter: - from ..plotters.sec_plotter import SECPlotter - - self._plotter = SECPlotter(measurement=self) - - return self._plotter - - @property - def exporter(self): - """The default plotter for SpectroECMeasurement is SECExporter""" - if not self._exporter: - self._exporter = SECExporter(measurement=self) - return self._exporter - def calc_dOD(self, V_ref=None, t_ref=None, index_ref=None): """Calculate the optical density with respect to a reference From af5be33281ceed65f59e20ef0e46572da00b6f66 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Mon, 6 Dec 2021 07:52:45 +0000 Subject: [PATCH 089/118] update __init__.py: aiming for v0.2.0 --- src/ixdat/__init__.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/ixdat/__init__.py b/src/ixdat/__init__.py index 713c332e..35d8b55a 100644 --- a/src/ixdat/__init__.py +++ b/src/ixdat/__init__.py @@ -1,12 +1,11 @@ """initialize ixdat, giving top-level access to a few of the important structures """ -__version__ = "0.1.6" +__version__ = "0.2.0dev" __title__ = "ixdat" __description__ = "The in-situ experimental data tool" -__url__ = "https://github.com/ixdat/ixdat" -__author__ = "Soren B. Scott, Kevin Krempl, Kenneth Nielsen" -__email__ = "scott.soren@gmail.com" # maybe we should get an orgianization email? -# __copyright__ = "Copyright (c) 2020 ixdat" +__url__ = "https://ixdat.readthedocs.io" +__author__ = "Soren B. Scott, Kenneth Nielsen, et al" +__email__ = "sbscott@ic.ac.uk" # maybe we should get an organization email? __license__ = "MIT" from .measurements import Measurement From ac8cf0c7ac4201b5bd51d76e5573c307d4cbfe09 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Mon, 6 Dec 2021 07:53:02 +0000 Subject: [PATCH 090/118] debug for ivium reader --- .../reader_testers/test_ivium_reader.py | 14 ++++++--- src/ixdat/measurements.py | 7 +++++ src/ixdat/readers/ivium.py | 31 +++++++++++++------ src/ixdat/readers/pfeiffer.py | 2 +- src/ixdat/readers/reading_tools.py | 21 ++++++++----- 5 files changed, 52 insertions(+), 23 deletions(-) diff --git a/development_scripts/reader_testers/test_ivium_reader.py b/development_scripts/reader_testers/test_ivium_reader.py index 699e9177..6c941245 100644 --- a/development_scripts/reader_testers/test_ivium_reader.py +++ b/development_scripts/reader_testers/test_ivium_reader.py @@ -2,6 +2,7 @@ import pandas as pd from matplotlib import pyplot as plt +from ixdat import Measurement from ixdat.techniques import CyclicVoltammagram path_to_file = Path.home() / ( @@ -10,11 +11,14 @@ path_to_single_file = path_to_file.parent / (path_to_file.name + "_1") df = pd.read_csv(path_to_single_file, sep=r"\s+", header=1) -meas = CyclicVoltammagram.read(path_to_file, reader="ivium") +meas = Measurement.read(path_to_file, reader="ivium") +meas.plot_measurement() -meas.save() +meas_cv = CyclicVoltammagram.read(path_to_file, reader="ivium") -meas.plot_measurement() -meas.redefine_cycle(start_potential=0.4, redox=False) +meas_cv.save() + +meas_cv.plot_measurement() +meas_cv.redefine_cycle(start_potential=0.4, redox=False) for i in range(4): - meas[i].plot() + meas_cv[i].plot() diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index 8d1fb79a..5130ac13 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -602,6 +602,13 @@ def get_series(self, key): if key in self.aliases: # i # Then we'll look up the aliases instead and append them for k in self.aliases[key]: + if k == key: # this would result in infinite recursion. + print( # TODO: Real warnings. + "WARNING!!!\n" + f"\t{self} has {key} in its aliases for {key}:\n" + f"\tself.aliases['{key}'] = {self.aliases[key]}" + ) + continue try: series_to_append.append(self[k]) except SeriesNotFoundError: diff --git a/src/ixdat/readers/ivium.py b/src/ixdat/readers/ivium.py index b7cb6da7..5a2440a5 100644 --- a/src/ixdat/readers/ivium.py +++ b/src/ixdat/readers/ivium.py @@ -3,20 +3,29 @@ import re from pathlib import Path import pandas as pd +from ..techniques.ec import ECMeasurement from .reading_tools import timestamp_string_to_tstamp, series_list_from_dataframe +IVIUM_ALIASES = { + "raw_potential": ("E/V",), + "raw_current": ("I/A",), + "t": ("time/s",), +} + class IviumDataReader: """Class for reading single ivium files""" - def read(self, path_to_file, cls=None, name=None, **kwargs): + def read(self, path_to_file, cls=None, name=None, cycle_number=0, **kwargs): """read the ascii export from the Ivium software Args: path_to_file (Path): The full abs or rel path including the suffix (.txt) - name (str): The name to use if not the file name cls (Measurement subclass): The Measurement class to return an object of. Defaults to `ECMeasurement`. + name (str): The name to use if not the file name + cycle_number (int): The cycle number of the data in the file (default is 0) + **kwargs (dict): Key-word arguments are passed to cls.__init__ Returns: @@ -25,7 +34,7 @@ def read(self, path_to_file, cls=None, name=None, **kwargs): self.path_to_file = Path(path_to_file) name = name or self.path_to_file.name - with open(path_to_file, "r") as f: + with open(self.path_to_file, "r") as f: timesting_line = f.readline() # we need this for tstamp columns_line = f.readline() # we need this to get the column names first_data_line = f.readline() # we need this to check the column names @@ -55,7 +64,11 @@ def read(self, path_to_file, cls=None, name=None, **kwargs): # into the Measurement, starting with the TimeSeries: data_series_list = series_list_from_dataframe( - dataframe, "time/s", tstamp, get_column_unit + dataframe, + time_name="time/s", + tstamp=tstamp, + unit_finding_function=get_column_unit, + cycle=cycle_number, ) # With the `series_list` ready, we prepare the Measurement dictionary and # return the Measurement object: @@ -63,16 +76,13 @@ def read(self, path_to_file, cls=None, name=None, **kwargs): name=name, technique="EC", reader=self, - raw_potential_names=("E/V",), - raw_current_names=("I/A",), + aliases=IVIUM_ALIASES, series_list=data_series_list, tstamp=tstamp, ) obj_as_dict.update(kwargs) - if not cls: - from ..techniques.ec import ECMeasurement - + if not issubclass(ECMeasurement, cls): cls = ECMeasurement return cls.from_dict(obj_as_dict) @@ -110,7 +120,8 @@ def read(self, path_to_file, cls=None, name=None, **kwargs): # in the folder who's name starts with base_name: all_file_paths = [f for f in folder.iterdir() if f.name.startswith(base_name)] component_measurements = [ - IviumDataReader().read(f, cls=cls) for f in all_file_paths + IviumDataReader().read(f, cls=cls, cycle_number=i) + for i, f in enumerate(all_file_paths) ] # Now we append these using the from_component_measurements class method of the diff --git a/src/ixdat/readers/pfeiffer.py b/src/ixdat/readers/pfeiffer.py index b065197d..8a4c41ee 100644 --- a/src/ixdat/readers/pfeiffer.py +++ b/src/ixdat/readers/pfeiffer.py @@ -44,7 +44,7 @@ def read(self, path_to_file, cls=None, name=None, **kwargs): series_list = series_list_from_dataframe( df, tstamp=tstamp, - t_str="Time Relative (sec)", + time_name="Time Relative (sec)", unit_finding_function=get_column_unit, ) meas_as_dict = { diff --git a/src/ixdat/readers/reading_tools.py b/src/ixdat/readers/reading_tools.py index 7c9f25df..6ea47454 100644 --- a/src/ixdat/readers/reading_tools.py +++ b/src/ixdat/readers/reading_tools.py @@ -5,7 +5,7 @@ import urllib.request from ..config import CFG from ..exceptions import ReadError -from ..measurements import TimeSeries, ValueSeries +from ..measurements import TimeSeries, ValueSeries, ConstantValue STANDARD_TIMESTAMP_FORM = "%d/%m/%Y %H:%M:%S" # like '31/12/2020 23:59:59' @@ -88,7 +88,9 @@ def prompt_for_tstamp(path_to_file, default="creation", form=STANDARD_TIMESTAMP_ return tstamp or default_tstamp -def series_list_from_dataframe(dataframe, t_str, tstamp, unit_finding_function): +def series_list_from_dataframe( + dataframe, time_name, tstamp, unit_finding_function, **kwargs +): """Return a list of DataSeries with the data in a pandas dataframe. Args: @@ -96,17 +98,18 @@ def series_list_from_dataframe(dataframe, t_str, tstamp, unit_finding_function): names, data is taken with series.to_numpy(). The dataframe can only have one TimeSeries (if there are more than one, pandas is probably not the right format anyway, since it requires columns be the same length). - t_str (str): The name of the column to use as the TimeSeries + time_name (str): The name of the column to use as the TimeSeries tstamp (float): The timestamp unit_finding_function (function): A function which takes a column name as a string and returns its unit. + kwargs: Additional key-word arguments are interpreted as constants to include + in the data series list as `ConstantValue`s. """ - tseries = TimeSeries( - name=t_str, unit_name="s", data=dataframe[t_str].to_numpy(), tstamp=tstamp - ) + t = dataframe[time_name].to_numpy() + tseries = TimeSeries(name=time_name, unit_name="s", data=t, tstamp=tstamp) data_series_list = [tseries] for column_name, series in dataframe.items(): - if column_name == t_str: + if column_name == time_name: continue data_series_list.append( ValueSeries( @@ -116,6 +119,10 @@ def series_list_from_dataframe(dataframe, t_str, tstamp, unit_finding_function): tseries=tseries, ) ) + for key, value in kwargs.items(): + data_series_list.append( + ConstantValue(name=key, unit_name="", data=value, tseries=tseries) + ) return data_series_list From b34c31cc859def01a215bf163620f64b944ca9a8 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Mon, 6 Dec 2021 10:44:19 +0000 Subject: [PATCH 091/118] mass_aliases are just aliases. --- src/ixdat/measurements.py | 12 ++++++ src/ixdat/readers/pfeiffer.py | 8 ++-- src/ixdat/techniques/ec_ms.py | 2 +- src/ixdat/techniques/ms.py | 74 ++++++----------------------------- 4 files changed, 29 insertions(+), 67 deletions(-) diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index 5130ac13..02413984 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -481,6 +481,18 @@ def aliases(self): """ return self._aliases.copy() + @property + def reverse_aliases(self): + """{series_name: standard_names} indicating how raw data can be accessed""" + rev_aliases = {} + for name, other_names in self.aliases.items(): + for n in other_names: + if n in rev_aliases: + rev_aliases[n].append(name) + else: + rev_aliases[n] = [name] + return rev_aliases + def get_series_names(self, key): """Return list: series names for key found by (recursive) lookup in aliases""" keys = [key] if key in self.series_names else [] diff --git a/src/ixdat/readers/pfeiffer.py b/src/ixdat/readers/pfeiffer.py index 8a4c41ee..66b0db19 100644 --- a/src/ixdat/readers/pfeiffer.py +++ b/src/ixdat/readers/pfeiffer.py @@ -38,8 +38,10 @@ def read(self, path_to_file, cls=None, name=None, **kwargs): df = pd.read_csv(self.path_to_file, header=6, delimiter="\t") # PV MassSpec calls masses _amu, information we need to pass on to # MSMeasurement, so that the data will be accessible by the 'M' mass string. - mass_aliases = { - mass_from_column_name(key): key for key in df.keys() if key.endswith("_amu") + aliases = { + mass_from_column_name(key): [key] + for key in df.keys() + if key.endswith("_amu") } series_list = series_list_from_dataframe( df, @@ -51,7 +53,7 @@ def read(self, path_to_file, cls=None, name=None, **kwargs): "name": name, "tstamp": tstamp, "series_list": series_list, - "mass_aliases": mass_aliases, + "aliases": aliases, "technique": "MS", } meas_as_dict.update(kwargs) diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index 000001d4..b7a1bf29 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -40,7 +40,7 @@ def __init__(self, **kwargs): ms_kwargs = { k: v for k, v in kwargs.items() if k in MSMeasurement.get_all_column_attrs() } - ms_kwargs["calibration"] = self.calibration # FIXME: This is a mess. + # ms_kwargs["calibration"] = self.calibration # FIXME: This is a mess. # FIXME: I think the lines below could be avoided with a PlaceHolderObject that # works together with MemoryBackend if "series_list" in kwargs: diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index 0bec1b19..d4229259 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -1,9 +1,9 @@ """Module for representation and analysis of MS measurements""" -from ..measurements import Measurement +from ..measurements import Measurement, Calibration from ..spectra import Spectrum from ..plotters.ms_plotter import MSPlotter, STANDARD_COLORS -from ..exceptions import SeriesNotFoundError, QuantificationError +from ..exceptions import QuantificationError from ..constants import ( AVOGADROS_CONSTANT, BOLTZMAN_CONSTANT, @@ -22,63 +22,8 @@ class MSMeasurement(Measurement): """Class implementing raw MS functionality""" - extra_column_attrs = { - "ms_meaurements": { - "mass_aliases", - "signal_bgs", - }, - } default_plotter = MSPlotter - def __init__( - self, - name, - mass_aliases=None, - signal_bgs=None, - tspan_bg=None, - calibration=None, - **kwargs, - ): - """Initializes a MS Measurement - - Args: - name (str): The name of the measurement - calibration (dict): calibration constants whereby the key - corresponds to the respective signal name. - mass_aliases (dict): {mass: mass_name} for any masses that - do not have the standard 'M' format used by ixdat. - signal_bgs (dict): {mass: S_bg} where S_bg is the background signal - in [A] for the mass (typically set with a timespan by `set_bg()`) - calibration (ECMSCalibration): A calibration for the MS signals - tspan_bg (timespan): background time used to set masses - """ - super().__init__(name, **kwargs) - self.calibration = calibration # TODO: Not final implementation - self.mass_aliases = mass_aliases or {} - self.signal_bgs = signal_bgs or {} - self.tspan_bg = tspan_bg - - def __getitem__(self, item): - """Try standard lookup, then check if item is a flux or alias for a mass""" - try: - return super().__getitem__(item) - except SeriesNotFoundError: - if item in self.mass_aliases: - return self[self.mass_aliases[item]] - if item.startswith("n_"): # it's a flux! - mol = item.split("_")[-1] - return self.get_flux_series(mol) - else: - raise - - def set_bg(self, tspan_bg=None, mass_list=None): - """Set background values for mass_list to the average signal during tspan_bg.""" - mass_list = mass_list or self.mass_list - tspan_bg = tspan_bg or self.tspan_bg - for mass in mass_list: - t, v = self.grab(mass, tspan_bg) - self.signal_bgs[mass] = np.mean(v) - def reset_bg(self, mass_list=None): """Reset background values for the masses in mass_list""" mass_list = mass_list or self.mass_list @@ -249,18 +194,17 @@ def mass_list(self): def is_mass(self, item): if re.search("^M[0-9]+$", item): return True - if item in self.mass_aliases.values(): + if item in self.reverse_aliases and self.is_mass(self.reverse_aliases[item][0]): return True return False def as_mass(self, item): if re.search("^M[0-9]+$", item): return item - else: - try: - return next(k for k, v in self.mass_aliases.items() if v == item) - except StopIteration: - raise TypeError(f"{self} does not recognize '{item}' as a mass.") + new_item = self.reverse_aliases[item][0] + if self.is_mass(new_item): + return self.as_mass(new_item) + raise TypeError(f"{self} does not recognize '{item}' as a mass.") class MSCalResult(Saveable): @@ -298,6 +242,10 @@ def color(self): return STANDARD_COLORS[self.mass] +class MSCalibration(Calibration): + pass + + class MSInlet: """A class for describing the inlet to the mass spec From fb63487fa9c03b25679be34b0c37fcf5895587a5 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Mon, 6 Dec 2021 17:26:46 +0000 Subject: [PATCH 092/118] signal_bgs and ms_cal_ist in MSCalibration --- development_scripts/functional_test.py | 2 +- src/ixdat/measurements.py | 71 ++++++++---- src/ixdat/techniques/ec.py | 24 ++-- src/ixdat/techniques/ec_ms.py | 75 ++++++------ src/ixdat/techniques/ms.py | 153 ++++++++++++++++++++++--- tests/functional/test_measurements.py | 6 +- 6 files changed, 240 insertions(+), 91 deletions(-) diff --git a/development_scripts/functional_test.py b/development_scripts/functional_test.py index 28daf901..aa6fb1d5 100644 --- a/development_scripts/functional_test.py +++ b/development_scripts/functional_test.py @@ -36,7 +36,7 @@ cv = ec_measurement.as_cv() cvs_1_plus_2 = cv[1] + cv[2] -# Check that the calibration survived all that: +# Check that the ms_calibration survived all that: assert cvs_1_plus_2.RE_vs_RHE == ec_measurement.RE_vs_RHE # Check that the main time variable, that of potential, wasn't corrupted: assert len(cvs_1_plus_2.grab("potential")[0]) == len(cvs_1_plus_2["time/s"].data) diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index 02413984..b51b2283 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -44,7 +44,7 @@ class Measurement(Saveable): } extra_linkers = { "component_measurements": ("measurements", "m_ids"), - "measurement_calibrations": ("calibration", "c_ids"), + "measurement_calibrations": ("ms_calibration", "c_ids"), "measurement_series": ("data_series", "s_ids"), } child_attrs = ["component_measurements", "calibration_list", "series_list"] @@ -508,9 +508,9 @@ def __getitem__(self, key): 2. find or build the desired data series by the first possible of: A. Check if `key` corresponds to a method in `series_constructors`. If so, build the data series with that method. - B. Check if the `calibration`'s `calibrate_series` returns a data series + B. Check if the `ms_calibration`'s `calibrate_series` returns a data series for `key` given the data in this measurement. (Note that the - `calibration` will typically start with raw data looked C, below.) + `ms_calibration` will typically start with raw data looked C, below.) C. Generate a list of data series and append them: i. Check if `key` is in `aliases`. If so, append all the data series returned for each key in `aliases[key]`. @@ -539,11 +539,11 @@ def __getitem__(self, key): - The first lookup, with `key="raw_potential"`, (1) checks for "raw_potential" in the cache, doesn't find it; then (2A) checks in - `series_constructors`, doesn't find it; (2B) asks the calibration for + `series_constructors`, doesn't find it; (2B) asks the ms_calibration for "raw_potential" and doesn't get anything back; and finally (2Ci) checks `aliases` for raw potential where it finds that "raw_potential" is called "Ewe/V". Then it looks up again, this time with `key="Ewe/V"`, which it doesn't - find in (1) the cache, (2A) `series_consturctors`, (2B) the calibration, or + find in (1) the cache, (2A) `series_consturctors`, (2B) the ms_calibration, or (2Ci) `aliases`, but does find in (2Cii) `series_list`. There is only one data series named "Ewe/V" so no appending is necessary, but it does ensure that the series has the measurement's `tstamp` before cache'ing and returning it. @@ -552,12 +552,12 @@ def __getitem__(self, key): returns it. - The second lookup, with `key="potential"`, (1) checks for "potential" in the cache, doesn't find it; then (2A) checks in `series_constructors`, doesn't find - it; and then (2B) asks the calibration for "potential". The calibration knows + it; and then (2B) asks the ms_calibration for "potential". The ms_calibration knows that when asked for "potential" it should look for "raw_potential" and add `RE_vs_RHE`. So it does a lookup with `key="raw_potential"` and (1) finds it - in the cache. The calibration does the math and returns a new data series for + in the cache. The ms_calibration does the math and returns a new data series for the calibrated potential, bringing us back to the original lookup. The data - series returned by the calibration is then (3) cached and returned to the user. + series returned by the ms_calibration is then (3) cached and returned to the user. Note that, if the user had not looked up "raw_potential" before looking up "potential", "raw_potential" would not have been in the cache and the first @@ -587,7 +587,7 @@ def get_series(self, key): See more detailed documentation under `__getitem__`, for which this is a helper method. This method (A) looks for a method for `key` in the measurement's - `series_constructors`; (B) requests its `calibration` for `key`; and if those + `series_constructors`; (B) requests its `ms_calibration` for `key`; and if those fails appends the data series that either (Ci) are returned by looking up the key's `aliases` or (Cii) have `key` as their name; and finally (D) check if the user was using a key with a suffix. @@ -604,7 +604,7 @@ def get_series(self, key): # B for calibration in self.calibrations: series = calibration.calibrate_series(key, measurement=self) - # ^ the calibration will call __getitem__ with the name of the + # ^ the ms_calibration will call __getitem__ with the name of the # corresponding raw data and return a new series with calibrated data # if possible. Otherwise it will return None. if series: @@ -648,8 +648,8 @@ def replace_series(self, series_name, new_series=None): """Remove an existing series, add a series to the measurement, or both. FIXME: This will not appear to change the series for the user if the - measurement's calibration returns something for ´series_name´, since - __getitem__ asks the calibration before looking in series_list. + measurement's ms_calibration returns something for ´series_name´, since + __getitem__ asks the ms_calibration before looking in series_list. Args: series_name (str): The name of a series. If the measurement has (raw) data @@ -1197,7 +1197,7 @@ def join(self, other, join_on=None): class Calibration(Saveable): """Base class for calibrations.""" - table_name = "calibration" + table_name = "ms_calibration" column_attrs = { "name", "technique", @@ -1208,9 +1208,9 @@ def __init__(self, name=None, technique=None, tstamp=None, measurement=None): """Initiate a Calibration Args: - name (str): The name of the calibration - technique (str): The technique of the calibration - tstamp (float): The time at which the calibration took place or is valid + name (str): The name of the ms_calibration + technique (str): The technique of the ms_calibration + tstamp (float): The time at which the ms_calibration took place or is valid measurement (Measurement): Optional. A measurement to calibrate by default. """ super().__init__() @@ -1237,13 +1237,42 @@ def from_dict(cls, obj_as_dict): else: calibration_class = cls try: - measurement = calibration_class(**obj_as_dict) + calibration = calibration_class(**obj_as_dict) except Exception: raise - return measurement + return calibration + + def as_dict(self): + """Have to dict the MSCalResults to get serializable as_dict (see Saveable)""" + self_as_dict = super().as_dict() + self_as_dict["ms_cal_results"] = [cal.as_dict() for cal in self.ms_cal_results] + return self_as_dict + + @classmethod + def from_dict(cls, obj_as_dict): + """Unpack the MSCalResults when initiating from a dict""" + obj = super(ECMSCalibration, cls).from_dict(obj_as_dict) + obj.ms_cal_results = [ + MSCalResult.from_dict(cal_as_dict) for cal_as_dict in obj.ms_cal_results + ] + return obj + + def export(self, path_to_file=None): + """Export an ECMSCalibration as a json-formatted text file""" + path_to_file = path_to_file or (self.name + ".ix") + self_as_dict = self.as_dict() + with open(path_to_file, "w") as f: + json.dump(self_as_dict, f, indent=4) + + @classmethod + def read(cls, path_to_file): + """Read an ECMSCalibration from a json-formatted text file""" + with open(path_to_file) as f: + obj_as_dict = json.load(f) + return cls.from_dict(obj_as_dict) def calibrate_series(self, key, measurement=None): - """This should be overwritten in real calibration classes. + """This should be overwritten in real ms_calibration classes. FIXME: Add more documentation about how to write this in inheriting classes. """ @@ -1275,5 +1304,5 @@ def get_combined_technique(technique_1, technique_2): if hyphenated in TECHNIQUE_CLASSES: return hyphenated - # if all else fails, we just join them with " AND ". e.g. MS + XRD = MS AND XRD - return technique_1 + " AND " + technique_2 + # if all else fails, we just join them with " and ". e.g. MS + XRD = MS and XRD + return technique_1 + " and " + technique_2 diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index 4e8fad8b..8d2d9114 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -33,7 +33,7 @@ class ECMeasurement(Measurement): - 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 + - A name that makes clear any ms_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 if the name of the raw `ValueSeries` (what the acquisition software calls it) changes. Indeed @@ -157,8 +157,8 @@ def calibrations(self): """The list of calibrations of the measurement. The following is necessary to ensure that all EC Calibration parameters are - joined in a single calibration when processing. So that "potential" is both - calibrated to RHE and ohmic drop corrected, even if the two calibration + joined in a single ms_calibration when processing. So that "potential" is both + calibrated to RHE and ohmic drop corrected, even if the two ms_calibration parameters were added separately. """ full_calibration_list = self.calibration_list @@ -172,7 +172,7 @@ def calibrations(self): @property def ec_calibration(self): - """A calibration joining the first RE_vs_RHE, A_el, and R_Ohm""" + """A ms_calibration joining the first RE_vs_RHE, A_el, and R_Ohm""" return ECCalibration(RE_vs_RHE=self.RE_vs_RHE, A_el=self.A_el, R_Ohm=self.R_Ohm) @property @@ -210,9 +210,9 @@ def calibrate( 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] - tstamp (flaot): The timestamp at which the calibration was done (defaults + tstamp (flaot): The timestamp at which the ms_calibration was done (defaults to now) - cal_name (str): The name of the calibration. + cal_name (str): The name of the ms_calibration. """ if not (RE_vs_RHE or A_el or R_Ohm): print("Warning! Ignoring attempt to calibrate without any parameters.") @@ -284,7 +284,7 @@ def as_cv(self): class ECCalibration(Calibration): - """An electrochemical calibration with RE_vs_RHE, A_el, and/or R_Ohm""" + """An electrochemical ms_calibration with RE_vs_RHE, A_el, and/or R_Ohm""" extra_column_attrs = {"ec_calibration": {"RE_vs_RHE", "A_el", "R_Ohm"}} # TODO: https://github.com/ixdat/ixdat/pull/11#discussion_r677552828 @@ -302,9 +302,9 @@ def __init__( """Initiate a Calibration Args: - name (str): The name of the calibration - technique (str): The technique of the calibration - tstamp (float): The time at which the calibration took place or is valid + name (str): The name of the ms_calibration + technique (str): The technique of the ms_calibration + tstamp (float): The time at which the ms_calibration took place or is valid measurement (ECMeasurement): Optional. A measurement to calibrate by default RE_vs_RHE (float): The reference electrode potential on the RHE scale in [V] A_el (float): The electrode area in [cm^2] @@ -329,11 +329,11 @@ def calibrate_series(self, key, measurement=None): Key should be "potential" or "current". Anything else will return None. - - potential: the calibration looks up "raw_potential" in the measurement, shifts + - potential: the ms_calibration looks up "raw_potential" in the measurement, shifts it to the RHE potential if RE_vs_RHE is available, corrects it for Ohmic drop if R_Ohm is available, and then returns a calibrated potential series with a name indicative of the corrections done. - - current: The calibration looks up "raw_current" in the measurement, normalizes + - current: The ms_calibration looks up "raw_current" in the measurement, normalizes it to the electrode area if A_el is available, and returns a calibrated current series with a name indicative of whether the normalization was done. """ diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index b7a1bf29..73636cc9 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -1,13 +1,12 @@ """Module for representation and analysis of EC-MS measurements""" import numpy as np from ..constants import FARADAY_CONSTANT -from .ec import ECMeasurement -from .ms import MSMeasurement, MSCalResult +from .ec import ECMeasurement, ECCalibration +from .ms import MSMeasurement, MSCalResult, MSCalibration from .cv import CyclicVoltammagram from ..exporters.ecms_exporter import ECMSExporter from ..plotters.ecms_plotter import ECMSPlotter from ..plotters.ms_plotter import STANDARD_COLORS -from ..db import Saveable # FIXME: doesn't belong here. import json # FIXME: doesn't belong here. @@ -27,12 +26,6 @@ class ECMSMeasurement(ECMeasurement, MSMeasurement): default_exporter = ECMSExporter def __init__(self, **kwargs): - if "calibration" in kwargs and kwargs["calibration"]: - self.calibration = kwargs["calibration"] - else: - # FIXME: This is a slight mess. - # ECMeasurement should also have RE_vs_RHE and A_el in a calibration - self.calibration = ECMSCalibration() """FIXME: Passing the right key-word arguments on is a mess""" ec_kwargs = { k: v for k, v in kwargs.items() if k in ECMeasurement.get_all_column_attrs() @@ -40,7 +33,7 @@ def __init__(self, **kwargs): ms_kwargs = { k: v for k, v in kwargs.items() if k in MSMeasurement.get_all_column_attrs() } - # ms_kwargs["calibration"] = self.calibration # FIXME: This is a mess. + # ms_kwargs["ms_calibration"] = self.ms_calibration # FIXME: This is a mess. # FIXME: I think the lines below could be avoided with a PlaceHolderObject that # works together with MemoryBackend if "series_list" in kwargs: @@ -55,8 +48,8 @@ def __init__(self, **kwargs): def as_dict(self): self_as_dict = super().as_dict() - if self.calibration: - self_as_dict["calibration"] = self.calibration.as_dict() + if self.ms_calibration: + self_as_dict["ms_calibration"] = self.ms_calibration.as_dict() # FIXME: necessary because an ECMSCalibration is not serializeable # If it it was it would go into extra_column_attrs return self_as_dict @@ -64,11 +57,11 @@ def as_dict(self): @classmethod def from_dict(cls, obj_as_dict): """Unpack the ECMSCalibration when initiating from a dict""" - if "calibration" in obj_as_dict: - if isinstance(obj_as_dict["calibration"], dict): + if "ms_calibration" in obj_as_dict: + if isinstance(obj_as_dict["ms_calibration"], dict): # FIXME: This is a mess - obj_as_dict["calibration"] = ECMSCalibration.from_dict( - obj_as_dict["calibration"] + obj_as_dict["ms_calibration"] = ECMSCalibration.from_dict( + obj_as_dict["ms_calibration"] ) obj = super(ECMSMeasurement, cls).from_dict(obj_as_dict) return obj @@ -94,7 +87,7 @@ def ecms_calibration(self, mol, mass, n_el, tspan, tspan_bg=None): tspan (tspan): The timespan of steady electrolysis tspan_bg (tspan): The time to use as a background - Return MSCalResult: The result of the calibration + Return MSCalResult: The result of the ms_calibration """ Y = self.integrate_signal(mass, tspan=tspan, tspan_bg=tspan_bg) Q = self.integrate("raw current / [mA]", tspan=tspan) * 1e-3 @@ -128,12 +121,12 @@ def ecms_calibration_curve( sign! e.g. +4 for O2 by OER and -2 for H2 by HER) tspan_list (list of tspan): THe timespans of steady electrolysis tspan_bg (tspan): The time to use as a background - ax (Axis): The axis on which to plot the calibration curve result. Defaults + ax (Axis): The axis on which to plot the ms_calibration curve result. Defaults to a new axis. axes_measurement (list of Axes): The EC-MS plot axes to highlight the - calibration on. Defaults to None. + ms_calibration on. Defaults to None. - Return MSCalResult(, Axis(, Axis)): The result of the calibration + Return MSCalResult(, Axis(, Axis)): The result of the ms_calibration (and requested axes) """ axis_ms = axes_measurement[0] if axes_measurement else None @@ -208,14 +201,12 @@ def __init__(self, **kwargs): ms_kwargs.update(series_list=kwargs["series_list"]) MSMeasurement.__init__(self, **ms_kwargs) self.plot = self.plotter.plot_vs_potential - # FIXME: only necessary because an ECMSCalibration is not seriealizeable. - self.calibration = kwargs.get("calibration", None) def as_dict(self): self_as_dict = super().as_dict() - if self.calibration: - self_as_dict["calibration"] = self.calibration.as_dict() + if self.ms_calibration: + self_as_dict["ms_calibration"] = self.ms_calibration.as_dict() # FIXME: now that ECMSCalibration should be seriealizeable, it could # go into extra_column_attrs. But it should be a reference. return self_as_dict @@ -223,21 +214,21 @@ def as_dict(self): @classmethod def from_dict(cls, obj_as_dict): """Unpack the ECMSCalibration when initiating from a dict""" - if "calibration" in obj_as_dict: - if isinstance(obj_as_dict["calibration"], dict): + if "ms_calibration" in obj_as_dict: + if isinstance(obj_as_dict["ms_calibration"], dict): # FIXME: This is a mess - obj_as_dict["calibration"] = ECMSCalibration.from_dict( - obj_as_dict["calibration"] + obj_as_dict["ms_calibration"] = ECMSCalibration.from_dict( + obj_as_dict["ms_calibration"] ) obj = super(ECMSCyclicVoltammogram, cls).from_dict(obj_as_dict) return obj -class ECMSCalibration(Saveable): +class ECMSCalibration(ECCalibration, MSCalibration): """Class for calibrations useful for ECMSMeasurements FIXME: A class in a technique module shouldn't inherit directly from Saveable. We - need to generalize calibration somehow. + need to generalize ms_calibration somehow. Also, ECMSCalibration should inherit from or otherwise use a class MSCalibration """ @@ -251,27 +242,29 @@ def __init__( date=None, setup=None, ms_cal_results=None, + signal_bgs=None, RE_vs_RHE=None, A_el=None, L=None, ): """ Args: - name (str): Name of the calibration - date (str): Date of the calibration - setup (str): Name of the setup where the calibration is made + name (str): Name of the ms_calibration + date (str): Date of the ms_calibration + setup (str): Name of the setup where the ms_calibration is made ms_cal_results (list of MSCalResult): The mass spec calibrations RE_vs_RHE (float): the RE potential in [V] A_el (float): the geometric electrode area in [cm^2] L (float): the working distance in [m] """ - super().__init__() - self.name = name or f"EC-MS calibration for {setup} on {date}" - self.date = date - self.setup = setup - self.ms_cal_results = ms_cal_results or [] - self.RE_vs_RHE = RE_vs_RHE - self.A_el = A_el + ECCalibration.__init__(self, name=name, A_el=A_el, RE_vs_RHE=RE_vs_RHE) + MSCalibration.__init__( + self, + date=date, + setup=setup, + ms_cal_results=ms_cal_results, + signal_bgs=signal_bgs, + ) self.L = L def as_dict(self): @@ -341,7 +334,7 @@ def get_F(self, mol, mass): return np.mean(np.array(F_list)) def scaled_to(self, ms_cal_result): - """Return a new calibration w scaled sensitivity factors to match one given""" + """Return a new ms_calibration w scaled sensitivity factors to match one given""" F_0 = self.get_F(ms_cal_result.mol, ms_cal_result.mass) scale_factor = ms_cal_result.F / F_0 calibration_as_dict = self.as_dict() diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index d4229259..68ad340e 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -22,8 +22,41 @@ class MSMeasurement(Measurement): """Class implementing raw MS functionality""" + extra_column_attrs = {"ms_measurement": ("tspan_bg",)} default_plotter = MSPlotter + def __init__(self, name, **kwargs): + tspan_bg = kwargs.pop("tspan_bg", None) + super().__init__(name, **kwargs) + self.tspan_bg = tspan_bg + + @property + def ms_calibration(self): + ms_cal_list = [] + tspan_bg = None + signal_bgs = {} + for cal in self.calibration_list: + ms_cal_list = ms_cal_list + getattr(cal, "ms_cal_list", []) + for mass, bg in getattr(cal, "signal_bgs", {}).items(): + if not mass in signal_bgs: + signal_bgs[mass] = bg + tspan_bg = tspan_bg or getattr(cal, "tspan_bg", None) + return MSCalibration(ms_cal_results=ms_cal_list, signal_bgs=signal_bgs) + + @property + def signal_bgs(self): + return self.ms_calibration.signal_bgs + + def set_bg(self, tspan_bg=None, mass_list=None): + """Set background values for mass_list to the average signal during tspan_bg.""" + mass_list = mass_list or self.mass_list + tspan_bg = tspan_bg or self.tspan_bg + signal_bgs = {} + for mass in mass_list: + t, v = self.grab(mass, tspan_bg) + signal_bgs[mass] = np.mean(v) + self.add_calibration(MSCalibration(signal_bgs=signal_bgs)) + def reset_bg(self, mass_list=None): """Reset background values for the masses in mass_list""" mass_list = mass_list or self.mass_list @@ -65,7 +98,7 @@ def grab_signal( def grab_cal_signal(self, signal_name, tspan=None, t_bg=None): """Returns a calibrated signal for a given signal name. Only works if - calibration dict is not None. + ms_calibration dict is not None. Args: signal_name (str): Name of the signal. @@ -75,13 +108,13 @@ def grab_cal_signal(self, signal_name, tspan=None, t_bg=None): """ # TODO: Not final implementation. # FIXME: Depreciated! Use grab_flux instead! - if self.calibration is None: - print("No calibration dict found.") + if self.ms_calibration is None: + print("No ms_calibration dict found.") return time, value = self.grab_signal(signal_name, tspan=tspan, t_bg=t_bg) - return time, value * self.calibration[signal_name] + return time, value * self.ms_calibration[signal_name] def grab_flux( self, @@ -94,7 +127,7 @@ def grab_flux( """Return the flux of mol (calibrated signal) in [mol/s] Args: - mol (str or MSCalResult): Name of the molecule or a calibration thereof + mol (str or MSCalResult): Name of the molecule or a ms_calibration thereof tspan (list): Timespan for which the signal is returned. tspan_bg (list): Timespan that corresponds to the background signal. If not given, no background is subtracted. @@ -102,12 +135,12 @@ def grab_flux( Defaults to True. """ if isinstance(mol, str): - if not self.calibration or mol not in self.calibration: + if not self.ms_calibration or mol not in self.ms_calibration: raise QuantificationError( f"Can't quantify {mol} in {self}: " - f"Not in calibration={self.calibration}" + f"Not in ms_calibration={self.ms_calibration}" ) - mass, F = self.calibration.get_mass_and_F(mol) + mass, F = self.ms_calibration.get_mass_and_F(mol) elif isinstance(mol, MSCalResult): mass = mol.mass F = mol.F @@ -208,9 +241,9 @@ def as_mass(self, item): class MSCalResult(Saveable): - """A class for a mass spec calibration result. + """A class for a mass spec ms_calibration result. - TODO: How can we generalize calibration? I think that something inheriting directly + TODO: How can we generalize ms_calibration? I think that something inheriting directly from saveable belongs in a top-level module and not in a technique module """ @@ -243,7 +276,101 @@ def color(self): class MSCalibration(Calibration): - pass + """Class for calibrations useful for ECMSMeasurements + + FIXME: A class in a technique module shouldn't inherit directly from Saveable. We + need to generalize ms_calibration somehow. + Also, ECMSCalibration should inherit from or otherwise use a class MSCalibration + """ + + extra_linkers = {"ms_calibration_results", ("ms_cal_results", "ms_cal_result_ids")} + child_attrs = [ + "ms_cal_results", + ] + # FIXME: Not given a table_name as it can't save to the database without + # MSCalResult's being json-seriealizeable. Exporting and reading works, though :D + + def __init__( + self, + name=None, + date=None, + setup=None, + ms_cal_results=None, + signal_bgs=None, + ): + """ + Args: + name (str): Name of the ms_calibration + date (str): Date of the ms_calibration + setup (str): Name of the setup where the ms_calibration is made + ms_cal_results (list of MSCalResult): The mass spec calibrations + """ + super().__init__() + self.name = name or f"EC-MS ms_calibration for {setup} on {date}" + self.date = date + self.setup = setup + self.ms_cal_results = ms_cal_results or [] + self.signal_bgs = signal_bgs or {} + + @property + def ms_cal_result_ids(self): + return [cal.id for cal in self.ms_cal_results] + + @property + def mol_list(self): + return list({cal.mol for cal in self.ms_cal_results}) + + @property + def mass_list(self): + return list({cal.mass for cal in self.ms_cal_results}) + + @property + def name_list(self): + return list({cal.name for cal in self.ms_cal_results}) + + def __contains__(self, mol): + return mol in self.mol_list or mol in self.name_list + + def __iter__(self): + yield from self.ms_cal_results + + def get_mass_and_F(self, mol): + """Return the mass and sensitivity factor to use for simple quant. of mol""" + cal_list_for_mol = [cal for cal in self if cal.mol == mol or cal.name == mol] + Fs = [cal.F for cal in cal_list_for_mol] + index = np.argmax(np.array(Fs)) + + the_good_cal = cal_list_for_mol[index] + return the_good_cal.mass, the_good_cal.F + + def get_F(self, mol, mass): + """Return the sensitivity factor for mol at mass""" + cal_list_for_mol_at_mass = [ + cal + for cal in self + if (cal.mol == mol or cal.name == mol) and cal.mass == mass + ] + F_list = [cal.F for cal in cal_list_for_mol_at_mass] + return np.mean(np.array(F_list)) + + def scaled_to(self, ms_cal_result): + """Return a new ms_calibration w scaled sensitivity factors to match one given""" + F_0 = self.get_F(ms_cal_result.mol, ms_cal_result.mass) + scale_factor = ms_cal_result.F / F_0 + calibration_as_dict = self.as_dict() + new_cal_list = [] + for cal in self.ms_cal_results: + cal = MSCalResult( + name=cal.name, + mass=cal.mass, + mol=cal.mol, + F=cal.F * scale_factor, + cal_type=cal.cal_type + " scaled", + ) + new_cal_list.append(cal) + calibration_as_dict["ms_cal_results"] = [cal.as_dict() for cal in new_cal_list] + calibration_as_dict["name"] = calibration_as_dict["name"] + " scaled" + return self.__class__.from_dict(calibration_as_dict) class MSInlet: @@ -360,7 +487,7 @@ def gas_flux_calibration( ): """ Args: - measurement (MSMeasurement): The measurement with the calibration data + measurement (MSMeasurement): The measurement with the ms_calibration data mol (str): The name of the molecule to calibrate mass (str): The mass to calibrate at tspan (iter): The timespan to average the signal over. Defaults to all @@ -368,7 +495,7 @@ def gas_flux_calibration( ax (matplotlib axis): the axis on which to indicate what signal is used with a thicker line. Defaults to none - Returns MSCalResult: a calibration result containing the sensitivity factor for + Returns MSCalResult: a ms_calibration result containing the sensitivity factor for mol at mass """ t, S = measurement.grab_signal(mass, tspan=tspan, t_bg=tspan_bg) diff --git a/tests/functional/test_measurements.py b/tests/functional/test_measurements.py index dc10c2b7..6cdc164b 100644 --- a/tests/functional/test_measurements.py +++ b/tests/functional/test_measurements.py @@ -23,7 +23,7 @@ def test_basic_data(self, ec_measurement): ) def test_calibrate_and_append(self, ec_measurement): - """Test that measurement calibration works""" + """Test that measurement ms_calibration works""" ec_measurement.calibrate_RE(RE_vs_RHE=1) assert ec_measurement.v[0] - ec_measurement["raw_potential"].data[0] == approx( ec_measurement.RE_vs_RHE @@ -34,7 +34,7 @@ def test_calibrate_and_append(self, ec_measurement): cv = ec_measurement.as_cv() cvs_1_plus_2 = cv[1] + cv[2] - # Check that the calibration survived all that: + # Check that the ms_calibration survived all that: assert cvs_1_plus_2.RE_vs_RHE == ec_measurement.RE_vs_RHE # Check that the main time variable, that of potential, wasn't corrupted: assert len(cvs_1_plus_2.grab("potential")[0]) == len( @@ -75,7 +75,7 @@ def test_calibration_over_save_load(self, composed_measurement): # Now, try copying the calibrated measurement by as_dict() and from_dict(): meas12_copied = Measurement.from_dict(composed_measurement_copy.as_dict()) - # And check if it still has the calibration: + # And check if it still has the ms_calibration: assert meas12_copied.A_el == A_el # And that it can still apply it: assert meas12_copied.grab("potential")[1][0] == approx( From 39c97a5611bf04ee3620e567a5515ce4ec5b0b47 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Mon, 6 Dec 2021 18:04:55 +0000 Subject: [PATCH 093/118] debug autolab: cycle not essential to ECMeasurement --- .../reader_testers/test_autolab_reader.py | 2 ++ src/ixdat/measurements.py | 2 +- src/ixdat/readers/autolab.py | 9 +++++++-- src/ixdat/techniques/cv.py | 9 ++++++++- src/ixdat/techniques/ec.py | 2 +- 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/development_scripts/reader_testers/test_autolab_reader.py b/development_scripts/reader_testers/test_autolab_reader.py index e697851d..7e7ff398 100644 --- a/development_scripts/reader_testers/test_autolab_reader.py +++ b/development_scripts/reader_testers/test_autolab_reader.py @@ -14,3 +14,5 @@ ) meas = Measurement.read(path_to_file, reader="autolab") + +meas.plot() diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index b51b2283..f55f0487 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -211,7 +211,7 @@ def read(cls, path_to_file, reader, **kwargs): from .readers import READER_CLASSES reader = READER_CLASSES[reader]() - obj = reader.read(path_to_file, cls=cls, **kwargs) # TODO: take cls as kwarg + obj = reader.read(path_to_file, cls=cls, **kwargs) if obj.__class__.essential_series_names: for series_name in obj.__class__.essential_series_names: diff --git a/src/ixdat/readers/autolab.py b/src/ixdat/readers/autolab.py index e06f83fd..fd4ecd6f 100644 --- a/src/ixdat/readers/autolab.py +++ b/src/ixdat/readers/autolab.py @@ -10,6 +10,12 @@ timestamp_string_to_tstamp, ) +AUTOLAB_ALIASES = { + "raw_potential": ("WE(1).Potential (V)",), + "raw_current": ("WE(1).Current (A)",), + "t": ("Time (s)",), +} + class NovaASCIIReader: """A reader for ascii files exported by Autolab's Nova software""" @@ -54,8 +60,7 @@ def read( name=name, technique="EC", reader=self, - raw_potential_names=("WE(1).Potential (V)",), - raw_current_names=("WE(1).Current (A)",), + aliases=AUTOLAB_ALIASES, series_list=data_series_list, tstamp=tstamp, ) diff --git a/src/ixdat/techniques/cv.py b/src/ixdat/techniques/cv.py index 010a7539..3a783d87 100644 --- a/src/ixdat/techniques/cv.py +++ b/src/ixdat/techniques/cv.py @@ -1,7 +1,7 @@ import numpy as np from .ec import ECMeasurement from ..data_series import ValueSeries, TimeSeries -from ..exceptions import BuildError +from ..exceptions import BuildError, SeriesNotFoundError from .analysis_tools import ( tspan_passing_through, calc_sharp_v_scan, @@ -21,6 +21,7 @@ class CyclicVoltammagram(ECMeasurement): - the default plot() is plot_vs_potential() """ + essential_series_names = ("t", "raw_potential", "raw_current", "cycle") selector_name = "cycle" """Name of the default selector""" @@ -29,6 +30,12 @@ def __init__(self, *args, **kwargs): 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.v) + np.min(self.v)) + self.redefine_cycle(start_potential=median_potential, redox=True) + self.start_potential = None # see `redefine_cycle` self.redox = None # see `redefine_cycle` diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index 8d2d9114..4caa46e4 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -91,7 +91,7 @@ class ECMeasurement(Measurement): } } control_series_name = "raw_potential" - essential_series_names = ("t", "raw_potential", "raw_current", "cycle") + essential_series_names = ("t", "raw_potential", "raw_current") selection_series_names = ("file_number", "loop_number", "cycle") default_exporter = ECExporter default_plotter = ECPlotter From aa86808e55405cfecc15b21de887a319a960b003 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Mon, 6 Dec 2021 19:05:20 +0000 Subject: [PATCH 094/118] debug msrh_sec: series_list before aliases. --- .../reader_testers/test_msrh_sec_reader.py | 12 +++++----- src/ixdat/exporters/sec_exporter.py | 24 +++++++++++++++++-- src/ixdat/measurements.py | 10 ++++---- src/ixdat/plotters/sec_plotter.py | 16 ++++++------- src/ixdat/readers/msrh_sec.py | 7 ++++-- src/ixdat/spectra.py | 8 ++++--- .../techniques/spectroelectrochemistry.py | 7 ++++-- 7 files changed, 56 insertions(+), 28 deletions(-) diff --git a/development_scripts/reader_testers/test_msrh_sec_reader.py b/development_scripts/reader_testers/test_msrh_sec_reader.py index f5727ccc..0210ff22 100644 --- a/development_scripts/reader_testers/test_msrh_sec_reader.py +++ b/development_scripts/reader_testers/test_msrh_sec_reader.py @@ -26,14 +26,14 @@ sec_meas.get_dOD_spectrum(V=1.7, V_ref=1.4).plot(color="r", label="species 3", ax=ax) ax.legend() -export_name = "exported_sec.csv" -sec_meas.export(export_name) -sec_reloaded = Measurement.read(export_name, reader="ixdat") +if False: # test export and reload + export_name = "exported_sec.csv" + sec_meas.export(export_name) + sec_reloaded = Measurement.read(export_name, reader="ixdat") + sec_reloaded.set_reference_spectrum(V_ref=0.66) + sec_reloaded.plot_vs_potential(cmap_name="jet") -sec_reloaded.set_reference_spectrum(V_ref=0.66) - -sec_reloaded.plot_vs_potential(cmap_name="jet") axes = sec_meas.plot_measurement( V_ref=0.4, diff --git a/src/ixdat/exporters/sec_exporter.py b/src/ixdat/exporters/sec_exporter.py index 8e0dd979..59c9ad95 100644 --- a/src/ixdat/exporters/sec_exporter.py +++ b/src/ixdat/exporters/sec_exporter.py @@ -8,8 +8,28 @@ class SECExporter(CSVExporter): def __init__(self, measurement, delim=",\t"): super().__init__(measurement, delim=delim) - self.reference_exporter = SpectrumExporter(measurement.reference_spectrum) - self.spectra_exporter = SpectrumSeriesExporter(measurement.spectrum_series) + # FIXME: The lines below don't work because this __init__ gets called before + # the measurement's __init__ is finished. + # self.reference_exporter = SpectrumExporter(measurement.reference_spectrum) + # self.spectra_exporter = SpectrumSeriesExporter(measurement.spectrum_series) + self._reference_exporter = None + self._spectra_exporter = None + + @property + def reference_exporter(self): + if not self._reference_exporter: + self._reference_exporter = SpectrumExporter( + self.measurement.reference_spectrum + ) + return self._reference_exporter + + @property + def spectra_exporter(self): + if not self._spectra_exporter: + self._spectra_exporter = SpectrumSeriesExporter( + self.measurement.spectrum_series + ) + return self._spectra_exporter @property def default_v_list(self): diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index f55f0487..b0bfa1cb 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -611,7 +611,10 @@ def get_series(self, key): return series # C series_to_append = [] - if key in self.aliases: # i + if key in self.series_names: # ii + # Then we'll append any series matching the desired name + series_to_append += [s for s in self.series_list if s.name == key] + elif key in self.aliases: # i # Then we'll look up the aliases instead and append them for k in self.aliases[key]: if k == key: # this would result in infinite recursion. @@ -625,9 +628,6 @@ def get_series(self, key): series_to_append.append(self[k]) except SeriesNotFoundError: continue - elif key in self.series_names: # ii - # Then we'll append any series matching the desired name - series_to_append += [s for s in self.series_list if s.name == key] # If the key is something in the data, by now we have series to append. if series_to_append: # the following if's are to do as little extra manipulation as possible: @@ -780,7 +780,7 @@ def t_name(self): def _build_file_number_series(self): """Build a `file_number` series based on component measurements times.""" series_to_append = [] - for i, m in enumerate(self.component_measurements): + for i, m in enumerate(self.component_measurements or [self]): if ( self.control_technique_name and not m.technique == self.control_technique_name diff --git a/src/ixdat/plotters/sec_plotter.py b/src/ixdat/plotters/sec_plotter.py index 78a7a83d..15c3b364 100644 --- a/src/ixdat/plotters/sec_plotter.py +++ b/src/ixdat/plotters/sec_plotter.py @@ -129,7 +129,7 @@ def plot_waterfall( cmap_name=cmap_name, make_colorbar=make_colorbar, ax=ax, - vs=measurement.V_str, + vs=measurement.v_name, ) def plot_vs_potential( @@ -137,8 +137,8 @@ def plot_vs_potential( measurement=None, tspan=None, vspan=None, - V_str=None, - J_str=None, + v_name=None, + j_name=None, axes=None, wlspan=None, V_ref=None, @@ -158,8 +158,8 @@ def plot_vs_potential( self.measurement tspan (timespan): The timespan of data to keep for the measurement. vspan (timespan): The potential span of data to keep for the measurement. - V_str (str): Optional. The name of the data series to use as potential. - J_str (str): Optional. The name of the data series to use as current. + v_name (str): Optional. The name of the data series to use as potential. + j_name (str): Optional. The name of the data series to use as current. wlspan (iterable): The wavelength span of spectral data to plot axes (list of numpy Axes): The axes to plot on. axes[0] is for the heat plot and axes[1] for potential. New are made by default. @@ -184,8 +184,8 @@ def plot_vs_potential( self.ec_plotter.plot_vs_potential( measurement=measurement, tspan=tspan, - V_str=V_str, - J_str=J_str, + v_name=v_name, + j_name=j_name, ax=axes[1], **kwargs, ) @@ -198,7 +198,7 @@ def plot_vs_potential( ax=axes[0], cmap_name=cmap_name, make_colorbar=make_colorbar, - vs=V_str or measurement.V_str, + vs=v_name or measurement.v_name, ) axes[1].set_xlim(axes[0].get_xlim()) return axes diff --git a/src/ixdat/readers/msrh_sec.py b/src/ixdat/readers/msrh_sec.py index 794a6e29..9195c7e0 100644 --- a/src/ixdat/readers/msrh_sec.py +++ b/src/ixdat/readers/msrh_sec.py @@ -130,8 +130,11 @@ def read( name=str(path_to_file), tstamp=tstamp, series_list=series_list, - raw_potential_names=(v_series.name,), - raw_current_names=(j_series.name,), + aliases={ + "raw_potential": (v_series.name,), + "raw_current": (j_series.name,), + "t": (tseries.name,), + }, ) return measurement diff --git a/src/ixdat/spectra.py b/src/ixdat/spectra.py index 66f12fe2..f22c4a53 100644 --- a/src/ixdat/spectra.py +++ b/src/ixdat/spectra.py @@ -65,9 +65,11 @@ def __init__( self.tstamp = tstamp self.sample_name = sample_name self.reader = reader - self._field = field or PlaceHolderObject(field_id, cls=Field) + self._field = field or PlaceHolderObject( + field_id, cls=Field, backend=self.backend + ) - self.plotter = SpectrumPlotter + self.plotter = SpectrumPlotter(spectrum=self) # defining this method here gets it the right docstrings :D self.plot = self.plotter.plot @@ -250,7 +252,7 @@ def __init__(self, *args, **kwargs): if "technique" not in kwargs: kwargs["technique"] = "spectra" super().__init__(*args, **kwargs) - self.plotter = SpectrumSeriesPlotter + self.plotter = SpectrumSeriesPlotter(spectrum_series=self) @property def yseries(self): diff --git a/src/ixdat/techniques/spectroelectrochemistry.py b/src/ixdat/techniques/spectroelectrochemistry.py index d5a768d3..cc1c37e5 100644 --- a/src/ixdat/techniques/spectroelectrochemistry.py +++ b/src/ixdat/techniques/spectroelectrochemistry.py @@ -6,9 +6,14 @@ from scipy.interpolate import interp1d from ..spectra import SpectrumSeries from ..exporters.sec_exporter import SECExporter +from ..plotters.sec_plotter import SECPlotter class SpectroECMeasurement(ECMeasurement): + + default_plotter = SECPlotter + default_exporter = SECExporter + def __init__(self, *args, **kwargs): """Initialize an SEC measurement. All args and kwargs go to ECMeasurement.""" ECMeasurement.__init__(self, *args, **kwargs) @@ -18,8 +23,6 @@ def __init__(self, *args, **kwargs): self.plot_wavelengths = self.plotter.plot_wavelengths self.plot_wavelengths_vs_potential = self.plotter.plot_wavelengths_vs_potential self.technique = "S-EC" - self.plotter = SECPlotter(measurement=self) - self.exporter = SECExporter(measurement=self) @property def reference_spectrum(self): From 4e7c97c9145fbed5a6827c43c44e8b66e09c079d Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Mon, 6 Dec 2021 19:15:09 +0000 Subject: [PATCH 095/118] debug msrh_sec_decay --- .../reader_testers/test_msrh_sec_decay_reader.py | 2 +- src/ixdat/readers/msrh_sec.py | 7 +++++-- src/ixdat/techniques/spectroelectrochemistry.py | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/development_scripts/reader_testers/test_msrh_sec_decay_reader.py b/development_scripts/reader_testers/test_msrh_sec_decay_reader.py index b1c23e33..79a37fff 100644 --- a/development_scripts/reader_testers/test_msrh_sec_decay_reader.py +++ b/development_scripts/reader_testers/test_msrh_sec_decay_reader.py @@ -34,7 +34,7 @@ ax_w = sec_meas.plot_waterfall() -exit() +# exit() # ax_w.get_figure().savefig("decay_waterfall.png") ref_spec = sec_meas.reference_spectrum diff --git a/src/ixdat/readers/msrh_sec.py b/src/ixdat/readers/msrh_sec.py index 9195c7e0..9cd2ec12 100644 --- a/src/ixdat/readers/msrh_sec.py +++ b/src/ixdat/readers/msrh_sec.py @@ -235,8 +235,11 @@ def read( name=str(path_to_file), tstamp=tstamp, series_list=series_list, - raw_potential_names=(v_series.name,), - raw_current_names=(j_series.name,), + aliases={ + "raw_potential": (v_series.name,), + "raw_current": (j_series.name,), + "t": (tseries_v.name,), + }, ) return measurement diff --git a/src/ixdat/techniques/spectroelectrochemistry.py b/src/ixdat/techniques/spectroelectrochemistry.py index cc1c37e5..ec26e3b2 100644 --- a/src/ixdat/techniques/spectroelectrochemistry.py +++ b/src/ixdat/techniques/spectroelectrochemistry.py @@ -241,9 +241,9 @@ def track_wavelength(self, wl, width=10, V_ref=None, t_ref=None, index_ref=None) dOD_vseries = ValueSeries( name=dOD_name, unit_name="", data=dOD_wl, tseries=tseries ) - self[raw_name] = raw_vseries + self.replace_series(raw_name, raw_vseries) # FIXME: better caching. See https://github.com/ixdat/ixdat/pull/11 - self[dOD_name] = dOD_vseries + self.replace_series(dOD_name, dOD_vseries) # FIXME: better caching. See https://github.com/ixdat/ixdat/pull/11 self.tracked_wavelengths.append(dOD_name) # For the exporter. From 021bd8622fe7c99c9e0fd993dfad1793599e25ca Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Mon, 6 Dec 2021 20:23:33 +0000 Subject: [PATCH 096/118] debug ixdat csv exporters and ixdat reader --- .../reader_testers/test_ixdat_csv_reader.py | 27 ++++++++++++++----- .../reader_testers/test_msrh_sec_reader.py | 2 +- src/ixdat/exporters/csv_exporter.py | 7 +++++ src/ixdat/exporters/ec_exporter.py | 9 +++++++ src/ixdat/exporters/sec_exporter.py | 2 ++ src/ixdat/plotters/ec_plotter.py | 20 ++------------ src/ixdat/readers/ixdat_csv.py | 24 +++++++++++------ src/ixdat/techniques/ec.py | 24 ++++++++++++++--- 8 files changed, 78 insertions(+), 37 deletions(-) diff --git a/development_scripts/reader_testers/test_ixdat_csv_reader.py b/development_scripts/reader_testers/test_ixdat_csv_reader.py index 4aa6d45c..c98e59d0 100644 --- a/development_scripts/reader_testers/test_ixdat_csv_reader.py +++ b/development_scripts/reader_testers/test_ixdat_csv_reader.py @@ -1,8 +1,23 @@ from ixdat import Measurement -meas = Measurement.read_url( - "https://raw.githubusercontent.com/ixdat/tutorials/" - + "main/loading_appending_and_saving/co_strip.csv", - reader="ixdat", -) -meas.plot_measurement() + +if False: # test the version that's online on the tutorials page + meas = Measurement.read_url( + "https://raw.githubusercontent.com/ixdat/tutorials/" + + "main/loading_appending_and_saving/co_strip.csv", + reader="ixdat", + ) + meas.plot_measurement() + +else: + meas = Measurement.read( + "../../test_data/biologic/Pt_poly_cv_CUT.mpt", reader="biologic" + ) + meas.calibrate_RE(0.715) + meas.normalize_current(0.196) + + meas.as_cv().export("test.csv") + + meas_loaded = Measurement.read("test.csv", reader="ixdat") + + meas_loaded.plot() diff --git a/development_scripts/reader_testers/test_msrh_sec_reader.py b/development_scripts/reader_testers/test_msrh_sec_reader.py index 0210ff22..27e2f324 100644 --- a/development_scripts/reader_testers/test_msrh_sec_reader.py +++ b/development_scripts/reader_testers/test_msrh_sec_reader.py @@ -27,7 +27,7 @@ ax.legend() -if False: # test export and reload +if True: # test export and reload export_name = "exported_sec.csv" sec_meas.export(export_name) sec_reloaded = Measurement.read(export_name, reader="ixdat") diff --git a/src/ixdat/exporters/csv_exporter.py b/src/ixdat/exporters/csv_exporter.py index bd96ae09..f1dbad20 100644 --- a/src/ixdat/exporters/csv_exporter.py +++ b/src/ixdat/exporters/csv_exporter.py @@ -1,11 +1,15 @@ """Classes for exporting measurement data""" from pathlib import Path +import json class CSVExporter: """The default exporter, which writes delimited measurement data row-wise to file""" default_v_list = None # This will typically be overwritten by inheriting Exporters + """The names of the value series to export by default.""" + aliases = None # This will typically be overwritten by inheriting Exporters + """The aliases, needed for techniques with essential series that get renamed.""" def __init__(self, measurement=None, delim=",\t"): """Initiate the exported with a measurement (Measurement) and delimiter (str)""" @@ -83,6 +87,9 @@ def prepare_header_and_data(self, measurement, v_list, tspan): + "\n" ) header_lines.append(line) + if self.aliases: + aliases_line = f"aliases = {json.dumps(self.aliases)}\n" + header_lines.append(aliases_line) self.header_lines = header_lines self.s_list = s_list self.columns_data = columns_data diff --git a/src/ixdat/exporters/ec_exporter.py b/src/ixdat/exporters/ec_exporter.py index f07c87e5..d6864501 100644 --- a/src/ixdat/exporters/ec_exporter.py +++ b/src/ixdat/exporters/ec_exporter.py @@ -13,3 +13,12 @@ def default_v_list(self): self.measurement.j_name, self.measurement.selector_name, ] + + @property + def aliases(self): + return { + "t": (self.measurement.t_name,), + "raw_potential": (self.measurement.v_name,), + "raw_current": (self.measurement.j_name,), + "selector": (self.measurement.selector_name,), + } diff --git a/src/ixdat/exporters/sec_exporter.py b/src/ixdat/exporters/sec_exporter.py index 59c9ad95..d8043523 100644 --- a/src/ixdat/exporters/sec_exporter.py +++ b/src/ixdat/exporters/sec_exporter.py @@ -40,6 +40,8 @@ def default_v_list(self): ) return v_list + aliases = ECExporter.aliases + def prepare_header_and_data(self, measurement, v_list, tspan): """Do the standard ixdat csv export header preparation, plus SEC stuff. diff --git a/src/ixdat/plotters/ec_plotter.py b/src/ixdat/plotters/ec_plotter.py index 87300d22..a8cf4f11 100644 --- a/src/ixdat/plotters/ec_plotter.py +++ b/src/ixdat/plotters/ec_plotter.py @@ -61,27 +61,11 @@ def plot_measurement( "DEPRECIATION WARNING! V_str has been renamed v_name and J_str has " "been renamed j_name. Get it right next time." ) - v_name = ( - v_name - or V_str - or ( - measurement.v_name - if measurement.RE_vs_RHE is not None - else measurement.E_name - ) - ) + v_name = v_name or V_str or measurement.v_name # FIXME: We need a better solution for V_str and J_str that involves the # Calibration and is generalizable. see: # https://github.com/ixdat/ixdat/pull/11#discussion_r679290123 - j_name = ( - j_name - or J_str - or ( - measurement.j_name - if measurement.A_el is not None - else measurement.I_name - ) - ) + j_name = j_name or J_str or measurement.j_name t_v, v = measurement.grab(v_name, tspan=tspan) t_j, j = measurement.grab(j_name, tspan=tspan) if axes: diff --git a/src/ixdat/readers/ixdat_csv.py b/src/ixdat/readers/ixdat_csv.py index 04d9c60e..2927a4d2 100644 --- a/src/ixdat/readers/ixdat_csv.py +++ b/src/ixdat/readers/ixdat_csv.py @@ -1,6 +1,7 @@ """Module defining the ixdat csv reader, so ixdat can read the files it exports.""" from pathlib import Path +import json import numpy as np import re import pandas as pd @@ -72,6 +73,7 @@ def __init__(self): self.measurement_class = Measurement self.file_has_been_read = False self.measurement = None + self.meas_as_dict = {} def read(self, path_to_file, name=None, cls=None, **kwargs): """Return a Measurement with the data and metadata recorded in path_to_file @@ -149,24 +151,19 @@ def read(self, path_to_file, name=None, cls=None, **kwargs): data_series_dict[column_name] = vseries data_series_list = list(data_series_dict.values()) + self.aux_series_list - obj_as_dict = dict( + self.meas_as_dict.update( name=self.name, technique=self.technique, reader=self, series_list=data_series_list, tstamp=self.tstamp, ) - obj_as_dict.update(kwargs) + self.meas_as_dict.update(kwargs) if issubclass(cls, self.measurement_class): self.measurement_class = cls - if issubclass(self.measurement_class, TECHNIQUE_CLASSES["EC"]): - # this is how ECExporter exports current and potential: - obj_as_dict["raw_potential_names"] = ("raw potential / [V]",) - obj_as_dict["raw_current_names"] = ("raw current / [mA]",) - - self.measurement = self.measurement_class.from_dict(obj_as_dict) + self.measurement = self.measurement_class.from_dict(self.meas_as_dict) self.file_has_been_read = True return self.measurement @@ -208,11 +205,22 @@ def process_header_line(self, line): self.timecols[tcol] = [] for vcol in timecol_match.group(2).split("' and '"): self.timecols[tcol].append(vcol) + return aux_file_match = re.search(regular_expressions["aux_file"], line) if aux_file_match: aux_file_name = aux_file_match.group(1) aux_file = self.path_to_file.parent / aux_file_match.group(2) self.read_aux_file(aux_file, name=aux_file_name) + return + if " = " in line: + key, value = line.strip().split(" = ") + if key in ("name", "id"): + return + try: + self.meas_as_dict[key] = json.loads(value) + except json.decoder.JSONDecodeError: + print(f"skipping the following line:\n{line}") + return if self.N_header_lines and self.n_line >= self.N_header_lines - 2: self.place_in_file = "column names" diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index 4caa46e4..f4b905a3 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -95,10 +95,6 @@ class ECMeasurement(Measurement): selection_series_names = ("file_number", "loop_number", "cycle") default_exporter = ECExporter default_plotter = ECPlotter - v_name = EC_FANCY_NAMES["potential"] - j_name = EC_FANCY_NAMES["current"] - E_name = EC_FANCY_NAMES["raw_potential"] - I_name = EC_FANCY_NAMES["raw_current"] def __init__( self, @@ -145,6 +141,26 @@ def __init__( self.calibrate(RE_vs_RHE, A_el, R_Ohm) self.plot_vs_potential = self.plotter.plot_vs_potential + @property + def E_name(self): + return self["raw_potential"].name + + @property + def I_name(self): + return self["raw_current"].name + + @property + def v_name(self): + if self.RE_vs_RHE is not None: + return EC_FANCY_NAMES["potential"] + return self.E_name + + @property + def j_name(self): + if self.A_el is not None: + return EC_FANCY_NAMES["current"] + return self.I_name + @property def aliases(self): """A dictionary with the names of other data series a given name can refer to""" From 75a2d914254228585440fe3e78275dafaa3d5477 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Tue, 7 Dec 2021 11:52:37 +0000 Subject: [PATCH 097/118] debug for Huang2021: ECMSCalibration inheritance --- src/ixdat/measurements.py | 19 ++----------------- src/ixdat/readers/chi.py | 16 +++++++++++++++- src/ixdat/techniques/__init__.py | 10 +++++++--- src/ixdat/techniques/ec.py | 6 +++--- src/ixdat/techniques/ec_ms.py | 29 ++++++++++++++--------------- src/ixdat/techniques/ms.py | 30 ++++++++++++++++-------------- 6 files changed, 57 insertions(+), 53 deletions(-) diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index b0bfa1cb..d4f5f4fd 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -1197,14 +1197,14 @@ def join(self, other, join_on=None): class Calibration(Saveable): """Base class for calibrations.""" - table_name = "ms_calibration" + table_name = "calibration" column_attrs = { "name", "technique", "tstamp", } - def __init__(self, name=None, technique=None, tstamp=None, measurement=None): + def __init__(self, *, name=None, technique=None, tstamp=None, measurement=None): """Initiate a Calibration Args: @@ -1242,21 +1242,6 @@ def from_dict(cls, obj_as_dict): raise return calibration - def as_dict(self): - """Have to dict the MSCalResults to get serializable as_dict (see Saveable)""" - self_as_dict = super().as_dict() - self_as_dict["ms_cal_results"] = [cal.as_dict() for cal in self.ms_cal_results] - return self_as_dict - - @classmethod - def from_dict(cls, obj_as_dict): - """Unpack the MSCalResults when initiating from a dict""" - obj = super(ECMSCalibration, cls).from_dict(obj_as_dict) - obj.ms_cal_results = [ - MSCalResult.from_dict(cal_as_dict) for cal_as_dict in obj.ms_cal_results - ] - return obj - def export(self, path_to_file=None): """Export an ECMSCalibration as a json-formatted text file""" path_to_file = path_to_file or (self.name + ".ix") diff --git a/src/ixdat/readers/chi.py b/src/ixdat/readers/chi.py index 5ea88286..8bc7c27e 100644 --- a/src/ixdat/readers/chi.py +++ b/src/ixdat/readers/chi.py @@ -5,6 +5,16 @@ from ..techniques import ECMeasurement +CHI_LEGACY_ALIASES = { + # TODO: These should change to what Zilien calls them. Right now the alias's + # reflect the way the lagacy EC_MS code renames essential series + "t": ["time/s"], + "raw_potential": ["Ewe/V", "/V"], + "raw_current": ["I/mA", "/mA"], + "cycle": ["cycle number"], +} + + class CHInstrumentsTXTReader: path_to_file = None @@ -21,5 +31,9 @@ def read(self, path_to_file, cls=None): cls = cls if (cls and not issubclass(ECMeasurement, cls)) else ECMeasurement ec_ms_dataset = Dataset(path_to_file, data_type="CHI") return measurement_from_ec_ms_dataset( - ec_ms_dataset.data, cls=cls, reader=self, technique="EC" + ec_ms_dataset.data, + cls=cls, + reader=self, + technique="EC", + aliases=CHI_LEGACY_ALIASES, ) diff --git a/src/ixdat/techniques/__init__.py b/src/ixdat/techniques/__init__.py index 7dd37bf4..05520d7b 100644 --- a/src/ixdat/techniques/__init__.py +++ b/src/ixdat/techniques/__init__.py @@ -9,8 +9,8 @@ from .ec import ECMeasurement, ECCalibration from .cv import CyclicVoltammagram -from .ms import MSMeasurement -from .ec_ms import ECMSMeasurement +from .ms import MSMeasurement, MSCalibration +from .ec_ms import ECMSMeasurement, ECMSCalibration from .spectroelectrochemistry import SpectroECMeasurement from ..measurements import Measurement # for importing in the technique modules @@ -25,4 +25,8 @@ "S-EC": SpectroECMeasurement, } -CALIBRATION_CLASSES = {"EC": ECCalibration} +CALIBRATION_CLASSES = { + "EC": ECCalibration, + "MS": MSCalibration, + "EC-MS": ECMSCalibration, +} diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index f4b905a3..a0a78c14 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -307,9 +307,9 @@ class ECCalibration(Calibration): def __init__( self, + name=None, technique="EC", tstamp=None, - name=None, measurement=None, RE_vs_RHE=None, A_el=None, @@ -326,8 +326,8 @@ def __init__( A_el (float): The electrode area in [cm^2] R_Ohm (float): The ohmic drop resistance in [Ohm] """ - super().__init__( - name=name, technique=technique, tstamp=tstamp, measurement=measurement + Calibration.__init__( + self, name=name, technique=technique, tstamp=tstamp, measurement=measurement ) self.RE_vs_RHE = RE_vs_RHE self.A_el = A_el diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index 73636cc9..b8dfee88 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -225,27 +225,28 @@ def from_dict(cls, obj_as_dict): class ECMSCalibration(ECCalibration, MSCalibration): - """Class for calibrations useful for ECMSMeasurements + """Class for calibrations useful for ECMSMeasurements""" - FIXME: A class in a technique module shouldn't inherit directly from Saveable. We - need to generalize ms_calibration somehow. - Also, ECMSCalibration should inherit from or otherwise use a class MSCalibration - """ - - column_attrs = {"name", "date", "setup", "ms_cal_results", "RE_vs_RHE", "A_el", "L"} - # FIXME: Not given a table_name as it can't save to the database without - # MSCalResult's being json-seriealizeable. Exporting and reading works, though :D + extra_column_attrs = { + "ecms_calibrations": {"date", "setup", "RE_vs_RHE", "A_el", "L"} + } + # FIXME: The above should be covered by the parent classes. Needs metaprogramming! + # NOTE: technique, name, and tstamp in column_attrs are inherited from Calibration + # NOTE: ms_results_ids in extra_linkers is inherited from MSCalibration. + # NOTE: signal_bgs is left out def __init__( self, name=None, date=None, + tstamp=None, setup=None, ms_cal_results=None, signal_bgs=None, RE_vs_RHE=None, A_el=None, L=None, + technique="EC-MS", ): """ Args: @@ -261,18 +262,14 @@ def __init__( MSCalibration.__init__( self, date=date, + tstamp=tstamp, setup=setup, ms_cal_results=ms_cal_results, signal_bgs=signal_bgs, ) + self.technique = technique self.L = L - def as_dict(self): - """Have to dict the MSCalResults to get serializable as_dict (see Saveable)""" - self_as_dict = super().as_dict() - self_as_dict["ms_cal_results"] = [cal.as_dict() for cal in self.ms_cal_results] - return self_as_dict - @classmethod def from_dict(cls, obj_as_dict): """Unpack the MSCalResults when initiating from a dict""" @@ -286,6 +283,8 @@ def export(self, path_to_file=None): """Export an ECMSCalibration as a json-formatted text file""" path_to_file = path_to_file or (self.name + ".ix") self_as_dict = self.as_dict() + del self_as_dict["ms_cal_result_ids"] + self_as_dict["ms_cal_results"] = [cal.as_dict() for cal in self.ms_cal_results] with open(path_to_file, "w") as f: json.dump(self_as_dict, f, indent=4) diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index 68ad340e..eaa77531 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -243,10 +243,11 @@ def as_mass(self, item): class MSCalResult(Saveable): """A class for a mass spec ms_calibration result. - TODO: How can we generalize ms_calibration? I think that something inheriting directly - from saveable belongs in a top-level module and not in a technique module + FIXME: I think that something inheriting directly from Saveable does not belong in + a technique module. """ + table_name = "ms_cal_results" column_attrs = {"name", "mol", "mass", "cal_type", "F"} def __init__( @@ -276,27 +277,24 @@ def color(self): class MSCalibration(Calibration): - """Class for calibrations useful for ECMSMeasurements + """Class for mass spec calibrations. TODO: replace with powerful external package""" - FIXME: A class in a technique module shouldn't inherit directly from Saveable. We - need to generalize ms_calibration somehow. - Also, ECMSCalibration should inherit from or otherwise use a class MSCalibration - """ - - extra_linkers = {"ms_calibration_results", ("ms_cal_results", "ms_cal_result_ids")} + extra_linkers = {"ms_calibration_results": ("ms_cal_results", "ms_cal_result_ids")} + # FIXME: signal_bgs are not saved at present. Should they be a separate table + # of Saveable objects like ms_cal_results or should they be a single json value? child_attrs = [ "ms_cal_results", ] - # FIXME: Not given a table_name as it can't save to the database without - # MSCalResult's being json-seriealizeable. Exporting and reading works, though :D def __init__( self, name=None, date=None, + tstamp=None, # FIXME: No need to have both a date and a tstamp? setup=None, ms_cal_results=None, signal_bgs=None, + technique="MS", ): """ Args: @@ -305,8 +303,11 @@ def __init__( setup (str): Name of the setup where the ms_calibration is made ms_cal_results (list of MSCalResult): The mass spec calibrations """ - super().__init__() - self.name = name or f"EC-MS ms_calibration for {setup} on {date}" + super().__init__( + name=name or f"EC-MS ms_calibration for {setup} on {date}", + technique=technique, + tstamp=tstamp, + ) self.date = date self.setup = setup self.ms_cal_results = ms_cal_results or [] @@ -378,6 +379,7 @@ class MSInlet: Every MSInlet describes the rate and composition of the gas entering a mass spectrometer. The default is a Spectro Inlets EC-MS chip. + TODO: Replace with powerful external package. """ def __init__( @@ -391,7 +393,7 @@ def __init__( p=STANDARD_PRESSURE, verbose=True, ): - """Create a Chip object given its properties + """Create an MSInlet object given its properties. Args: l_cap (float): capillary length [m]. Defaults to design parameter. From 62dadabc382be69422cd74928bccb4151331ffb1 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Tue, 7 Dec 2021 17:32:37 +0000 Subject: [PATCH 098/118] flux calculation in MSCalibration.get_series --- src/ixdat/measurements.py | 2 +- src/ixdat/plotters/ms_plotter.py | 4 +- src/ixdat/techniques/ec_ms.py | 133 +++---------------------- src/ixdat/techniques/ms.py | 166 +++++++++++++++++++------------ 4 files changed, 121 insertions(+), 184 deletions(-) diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index d4f5f4fd..29742a02 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -1251,7 +1251,7 @@ def export(self, path_to_file=None): @classmethod def read(cls, path_to_file): - """Read an ECMSCalibration from a json-formatted text file""" + """Read a Calibration from a json-formatted text file""" with open(path_to_file) as f: obj_as_dict = json.load(f) return cls.from_dict(obj_as_dict) diff --git a/src/ixdat/plotters/ms_plotter.py b/src/ixdat/plotters/ms_plotter.py index f215fb94..4036917e 100644 --- a/src/ixdat/plotters/ms_plotter.py +++ b/src/ixdat/plotters/ms_plotter.py @@ -104,7 +104,7 @@ def plot_measurement( t, v = measurement.grab_signal( v_or_v_name, tspan=tspan, - t_bg=tspan_bg, + tspan_bg=tspan_bg, removebackground=removebackground, include_endpoints=False, ) @@ -236,7 +236,7 @@ def plot_vs( t_v, v = measurement.grab_signal( v_name, tspan=tspan, - t_bg=tspan_bg, + tspan_bg=tspan_bg, removebackground=removebackground, include_endpoints=False, ) diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index b8dfee88..46497b19 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -14,14 +14,12 @@ class ECMSMeasurement(ECMeasurement, MSMeasurement): """Class for raw EC-MS functionality. Parents: ECMeasurement and MSMeasurement""" extra_column_attrs = { - # FIXME: It would be more elegant if this carried over from both parents - # That might require some custom inheritance definition... - "ecms_meaurements": { - "mass_aliases", - "signal_bgs", - "ec_technique", - }, + "ecms_meaurements": {"ec_technique", "tspan_bg"}, } + # FIXME: It would be much more elegant if this carried over automatically from + # *both* parents, by appending the table columns... + # We'll see how the problem changes with the metaprogramming work. + default_plotter = ECMSPlotter default_exporter = ECMSExporter @@ -45,27 +43,6 @@ def __init__(self, **kwargs): ECMeasurement.__init__(self, **ec_kwargs) MSMeasurement.__init__(self, **ms_kwargs) - def as_dict(self): - self_as_dict = super().as_dict() - - if self.ms_calibration: - self_as_dict["ms_calibration"] = self.ms_calibration.as_dict() - # FIXME: necessary because an ECMSCalibration is not serializeable - # If it it was it would go into extra_column_attrs - return self_as_dict - - @classmethod - def from_dict(cls, obj_as_dict): - """Unpack the ECMSCalibration when initiating from a dict""" - if "ms_calibration" in obj_as_dict: - if isinstance(obj_as_dict["ms_calibration"], dict): - # FIXME: This is a mess - obj_as_dict["ms_calibration"] = ECMSCalibration.from_dict( - obj_as_dict["ms_calibration"] - ) - obj = super(ECMSMeasurement, cls).from_dict(obj_as_dict) - return obj - def as_cv(self): self_as_dict = self.as_dict() @@ -180,8 +157,7 @@ class ECMSCyclicVoltammogram(CyclicVoltammagram, MSMeasurement): # FIXME: It would be more elegant if this carried over from both parents # That might require some custom inheritance definition... "ecms_meaurements": { - "mass_aliases", - "signal_bgs", + "tspan_bg", "ec_technique", }, } @@ -202,15 +178,6 @@ def __init__(self, **kwargs): MSMeasurement.__init__(self, **ms_kwargs) self.plot = self.plotter.plot_vs_potential - def as_dict(self): - self_as_dict = super().as_dict() - - if self.ms_calibration: - self_as_dict["ms_calibration"] = self.ms_calibration.as_dict() - # FIXME: now that ECMSCalibration should be seriealizeable, it could - # go into extra_column_attrs. But it should be a reference. - return self_as_dict - @classmethod def from_dict(cls, obj_as_dict): """Unpack the ECMSCalibration when initiating from a dict""" @@ -270,83 +237,11 @@ def __init__( self.technique = technique self.L = L - @classmethod - def from_dict(cls, obj_as_dict): - """Unpack the MSCalResults when initiating from a dict""" - obj = super(ECMSCalibration, cls).from_dict(obj_as_dict) - obj.ms_cal_results = [ - MSCalResult.from_dict(cal_as_dict) for cal_as_dict in obj.ms_cal_results - ] - return obj - - def export(self, path_to_file=None): - """Export an ECMSCalibration as a json-formatted text file""" - path_to_file = path_to_file or (self.name + ".ix") - self_as_dict = self.as_dict() - del self_as_dict["ms_cal_result_ids"] - self_as_dict["ms_cal_results"] = [cal.as_dict() for cal in self.ms_cal_results] - with open(path_to_file, "w") as f: - json.dump(self_as_dict, f, indent=4) - - @classmethod - def read(cls, path_to_file): - """Read an ECMSCalibration from a json-formatted text file""" - with open(path_to_file) as f: - obj_as_dict = json.load(f) - return cls.from_dict(obj_as_dict) - - @property - def mol_list(self): - return list({cal.mol for cal in self.ms_cal_results}) - - @property - def mass_list(self): - return list({cal.mass for cal in self.ms_cal_results}) - - @property - def name_list(self): - return list({cal.name for cal in self.ms_cal_results}) - - def __contains__(self, mol): - return mol in self.mol_list or mol in self.name_list - - def __iter__(self): - yield from self.ms_cal_results - - def get_mass_and_F(self, mol): - """Return the mass and sensitivity factor to use for simple quant. of mol""" - cal_list_for_mol = [cal for cal in self if cal.mol == mol or cal.name == mol] - Fs = [cal.F for cal in cal_list_for_mol] - index = np.argmax(np.array(Fs)) - - the_good_cal = cal_list_for_mol[index] - return the_good_cal.mass, the_good_cal.F - - def get_F(self, mol, mass): - """Return the sensitivity factor for mol at mass""" - cal_list_for_mol_at_mass = [ - cal - for cal in self - if (cal.mol == mol or cal.name == mol) and cal.mass == mass - ] - F_list = [cal.F for cal in cal_list_for_mol_at_mass] - return np.mean(np.array(F_list)) - - def scaled_to(self, ms_cal_result): - """Return a new ms_calibration w scaled sensitivity factors to match one given""" - F_0 = self.get_F(ms_cal_result.mol, ms_cal_result.mass) - scale_factor = ms_cal_result.F / F_0 - calibration_as_dict = self.as_dict() - new_cal_list = [] - for cal in self.ms_cal_results: - cal = MSCalResult( - name=cal.name, - mass=cal.mass, - mol=cal.mol, - F=cal.F * scale_factor, - cal_type=cal.cal_type + " scaled", - ) - new_cal_list.append(cal) - calibration_as_dict["ms_cal_results"] = [cal.as_dict() for cal in new_cal_list] - calibration_as_dict["name"] = calibration_as_dict["name"] + " scaled" - return self.__class__.from_dict(calibration_as_dict) + def calibrate_series(self, key, measurement=None): + measurement = measurement or self.measurement + try_1 = ECCalibration.calibrate_series(self, key, measurement) + if try_1: + return try_1 + try_2 = MSCalibration.calibrate_series(self, key, measurement) + if try_2: + return try_2 diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index eaa77531..14b7967f 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -1,5 +1,9 @@ """Module for representation and analysis of MS measurements""" +import re +import numpy as np +import json # FIXME: This is for MSCalibration.export, but shouldn't have to be here. + from ..measurements import Measurement, Calibration from ..spectra import Spectrum from ..plotters.ms_plotter import MSPlotter, STANDARD_COLORS @@ -13,10 +17,8 @@ MOLECULAR_DIAMETERS, MOLAR_MASSES, ) -from ..data_series import TimeSeries, ValueSeries +from ..data_series import ValueSeries from ..db import Saveable -import re -import numpy as np class MSMeasurement(Measurement): @@ -64,57 +66,61 @@ def reset_bg(self, mass_list=None): if mass in self.signal_bgs: del self.signal_bgs[mass] - def grab_signal( + def grab( self, - signal_name, + item, tspan=None, - t_bg=None, - removebackground=False, + tspan_bg=None, include_endpoints=False, + removebackground=False, ): """Returns t, S where S is raw signal in [A] for a given signal name (ie mass) Args: - signal_name (str): Name of the signal. + item (str): Name of the signal. tspan (list): Timespan for which the signal is returned. - t_bg (list): Timespan that corresponds to the background signal. + tspan_bg (list): Timespan that corresponds to the background signal. If not given, no background is subtracted. - removebackground (bool): Whether to remove a pre-set background if available - Defaults to False. (Note in grab_flux it defaults to True.) + removebackground (bool): Whether to remove a pre-set background if available. + This is special to MSMeasurement. + Defaults to False, but in grab_flux it defaults to True. include_endpoints (bool): Whether to ensure tspan[0] and tspan[-1] are in t """ - time, value = self.grab( - signal_name, tspan=tspan, include_endpoints=include_endpoints + time, value = super().grab( + item, tspan=tspan, include_endpoints=include_endpoints ) - if t_bg is None: - if removebackground and signal_name in self.signal_bgs: - return time, value - self.signal_bgs[signal_name] - return time, value - - else: - _, bg = self.grab(signal_name, tspan=t_bg) + if tspan_bg: + _, bg = self.grab(item, tspan=tspan_bg) return time, value - np.average(bg) + elif removebackground: + if item in self.signal_bgs: + return time, value - self.signal_bgs[item] + elif self.tspan_bg: + _, bg = self.grab(item, tspan=self.tspan_bg) + return time, value - np.average(bg) + return time, value - def grab_cal_signal(self, signal_name, tspan=None, t_bg=None): - """Returns a calibrated signal for a given signal name. Only works if - ms_calibration dict is not None. + def grab_for_t(self, item, t, tspan_bg=None, removebackground=False): + """Return a numpy array with the value of item interpolated to time t Args: - signal_name (str): Name of the signal. - tspan (list): Timespan for which the signal is returned. - t_bg (list): Timespan that corresponds to the background signal. - If not given, no background is subtracted. + item (str): The name of the value to grab + t (np array): The time vector to grab the value for + tspan_bg (iterable): Optional. A timespan defining when `item` is at its + baseline level. The average value of `item` in this interval will be + subtracted from what is returned. + removebackground (bool): Whether to remove a pre-set background if available. + This is special to MSMeasurement. + Defaults to False, but in grab_flux it defaults to True. """ - # TODO: Not final implementation. - # FIXME: Depreciated! Use grab_flux instead! - if self.ms_calibration is None: - print("No ms_calibration dict found.") - return + t_0, v_0 = self.grab(item, tspan_bg=tspan_bg, removebackground=removebackground) + v = np.interp(t, t_0, v_0) + return v - time, value = self.grab_signal(signal_name, tspan=tspan, t_bg=t_bg) - - return time, value * self.ms_calibration[signal_name] + def grab_signal(self, *args, **kwargs): + """Alias for grab()""" + return self.grab(*args, **kwargs) def grab_flux( self, @@ -126,6 +132,10 @@ def grab_flux( ): """Return the flux of mol (calibrated signal) in [mol/s] + Note: + `grab_flux(mol, ...)` is identical to `grab(f"n_dot_{mol}", ...)` with + removebackround=True by default. An MSCalibration does the maths. + Args: mol (str or MSCalResult): Name of the molecule or a ms_calibration thereof tspan (list): Timespan for which the signal is returned. @@ -134,27 +144,13 @@ def grab_flux( removebackground (bool): Whether to remove a pre-set background if available Defaults to True. """ - if isinstance(mol, str): - if not self.ms_calibration or mol not in self.ms_calibration: - raise QuantificationError( - f"Can't quantify {mol} in {self}: " - f"Not in ms_calibration={self.ms_calibration}" - ) - mass, F = self.ms_calibration.get_mass_and_F(mol) - elif isinstance(mol, MSCalResult): - mass = mol.mass - F = mol.F - else: - raise TypeError("mol must be str or MSCalResult") - x, y = self.grab_signal( - mass, + return self.grab( + f"n_dot_{mol}", tspan=tspan, - t_bg=tspan_bg, + tspan_bg=tspan_bg, removebackground=removebackground, include_endpoints=include_endpoints, ) - n_dot = y / F - return x, n_dot def grab_flux_for_t( self, @@ -182,16 +178,9 @@ def grab_flux_for_t( y = np.interp(t, t_0, y_0) return y - def get_flux_series(self, mol, tspan=None): - """Return a ValueSeries with the calibrated flux of mol during tspan""" - t, n_dot = self.grab_flux(mol, tspan=tspan) - tseries = TimeSeries( - name="n_dot_" + mol + "-t", unit_name="s", data=t, tstamp=self.tstamp - ) - vseries = ValueSeries( - name="n_dot_" + mol, unit_name="mol/s", data=n_dot, tseries=tseries - ) - return vseries + def get_flux_series(self, mol): + """Return a ValueSeries with the calibrated flux of mol""" + return self[f"n_dot_{mol}"] def integrate_signal(self, mass, tspan, tspan_bg, ax=None): """Integrate a ms signal with background subtraction and evt. plotting @@ -295,6 +284,7 @@ def __init__( ms_cal_results=None, signal_bgs=None, technique="MS", + measurement=None, ): """ Args: @@ -302,11 +292,13 @@ def __init__( date (str): Date of the ms_calibration setup (str): Name of the setup where the ms_calibration is made ms_cal_results (list of MSCalResult): The mass spec calibrations + measurement (MSMeasurement): The measurement """ super().__init__( name=name or f"EC-MS ms_calibration for {setup} on {date}", technique=technique, tstamp=tstamp, + measurement=measurement, ) self.date = date self.setup = setup @@ -335,10 +327,34 @@ def __contains__(self, mol): def __iter__(self): yield from self.ms_cal_results + def calibrate_series(self, key, measurement=None): + measurement = measurement or self.measurement + if key.startswith("n_"): # it's a flux! + mol = key.split("_")[-1] + try: + mass, F = self.get_mass_and_F(mol) + except QuantificationError: + # Calibrations just return None when they can't get what's requested. + return + signal_series = measurement[mass] + y = signal_series.data + if mass in measurement.signal_bgs: + # FIXME: How to make this optional to user of MSMeasuremt.grab()? + y = y - measurement.signal_bgs[mass] + n_dot = y / F + return ValueSeries( + name=f"n_dot_{mol}", + unit_name="mol/s", + data=n_dot, + tseries=signal_series.tseries, + ) + def get_mass_and_F(self, mol): """Return the mass and sensitivity factor to use for simple quant. of mol""" cal_list_for_mol = [cal for cal in self if cal.mol == mol or cal.name == mol] Fs = [cal.F for cal in cal_list_for_mol] + if not Fs: + raise QuantificationError(f"{self} has no sensitivity factor for {mol}") index = np.argmax(np.array(Fs)) the_good_cal = cal_list_for_mol[index] @@ -352,6 +368,10 @@ def get_F(self, mol, mass): if (cal.mol == mol or cal.name == mol) and cal.mass == mass ] F_list = [cal.F for cal in cal_list_for_mol_at_mass] + if not F_list: + raise QuantificationError( + f"{self} has no sensitivity factor for {mol} at {mass}" + ) return np.mean(np.array(F_list)) def scaled_to(self, ms_cal_result): @@ -373,6 +393,28 @@ def scaled_to(self, ms_cal_result): calibration_as_dict["name"] = calibration_as_dict["name"] + " scaled" return self.__class__.from_dict(calibration_as_dict) + @classmethod + def read(cls, path_to_file): + """Read an MSCalibration from a json-formatted text file""" + with open(path_to_file) as f: + obj_as_dict = json.load(f) + # put the MSCalResults (exported as dicts) into objects: + obj_as_dict["ms_cal_results"] = [ + MSCalResult.from_dict(ms_cal_as_dict) + for ms_cal_as_dict in obj_as_dict["ms_cal_results"] + ] + return cls.from_dict(obj_as_dict) + + def export(self, path_to_file=None): + """Export an ECMSCalibration as a json-formatted text file""" + path_to_file = path_to_file or (self.name + ".ix") + self_as_dict = self.as_dict() + # replace the ms_cal_result ids with the dictionaries of the results themselves: + del self_as_dict["ms_cal_result_ids"] + self_as_dict["ms_cal_results"] = [cal.as_dict() for cal in self.ms_cal_results] + with open(path_to_file, "w") as f: + json.dump(self_as_dict, f, indent=4) + class MSInlet: """A class for describing the inlet to the mass spec From b65ddb48facacf03f15cf3024c276c442fe77809 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Tue, 7 Dec 2021 18:12:45 +0000 Subject: [PATCH 099/118] pacify flake8, pass checks --- src/ixdat/measurements.py | 19 ++++++++++--------- src/ixdat/techniques/ec.py | 15 ++++++++------- src/ixdat/techniques/ec_ms.py | 5 ++--- src/ixdat/techniques/ms.py | 6 +++--- .../techniques/spectroelectrochemistry.py | 6 +++--- 5 files changed, 26 insertions(+), 25 deletions(-) diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index 29742a02..d2296307 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -1,6 +1,6 @@ """This module defines the Measurement class, the central data structure of ixdat -An ixdat Measurement is a collection of references to DataSeries with the metadata needed +An ixdat Measurement is a collection of references to DataSeries and the metadata needed to combine them, i.e. "build" the combined dataset. It has a number of general methods to visualize and analyze the combined dataset. Measurement is also the base class for a number of technique-specific Measurement-derived classes. @@ -550,14 +550,15 @@ def __getitem__(self, key): Now we're back in the original lookup, from which __getitem__ (3) caches the data series (which still has the name "Ewe/V") as "raw_potential" and returns it. - - The second lookup, with `key="potential"`, (1) checks for "potential" in the - cache, doesn't find it; then (2A) checks in `series_constructors`, doesn't find - it; and then (2B) asks the ms_calibration for "potential". The ms_calibration knows - that when asked for "potential" it should look for "raw_potential" and add - `RE_vs_RHE`. So it does a lookup with `key="raw_potential"` and (1) finds it - in the cache. The ms_calibration does the math and returns a new data series for - the calibrated potential, bringing us back to the original lookup. The data - series returned by the ms_calibration is then (3) cached and returned to the user. + - The second lookup, with `key="potential"`, (1) checks for "potential" in + the cache, doesn't find it; then (2A) checks in `series_constructors`, + doesn't find it; and then (2B) asks the ms_calibration for "potential". The + ms_calibration knows that when asked for "potential" it should look for + "raw_potential" and add `RE_vs_RHE`. So it does a lookup with + `key="raw_potential"` and (1) finds it in the cache. The ms_calibration does + the math and returns a new data series for the calibrated potential, bringing + us back to the original lookup. The data series returned by the + ms_calibration is then (3) cached and returned to the user. Note that, if the user had not looked up "raw_potential" before looking up "potential", "raw_potential" would not have been in the cache and the first diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index a0a78c14..9b9f9590 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -345,13 +345,14 @@ def calibrate_series(self, key, measurement=None): Key should be "potential" or "current". Anything else will return None. - - potential: the ms_calibration looks up "raw_potential" in the measurement, shifts - it to the RHE potential if RE_vs_RHE is available, corrects it for Ohmic drop if - R_Ohm is available, and then returns a calibrated potential series with a name - indicative of the corrections done. - - current: The ms_calibration looks up "raw_current" in the measurement, normalizes - it to the electrode area if A_el is available, and returns a calibrated current - series with a name indicative of whether the normalization was done. + - potential: the ms_calibration looks up "raw_potential" in the measurement, + shifts it to the RHE potential if RE_vs_RHE is available, corrects it for + Ohmic drop if R_Ohm is available, and then returns a calibrated potential + series with a name indicative of the corrections done. + - current: The ms_calibration looks up "raw_current" in the measurement, + normalizes it to the electrode area if A_el is available, and returns a + calibrated current series with a name indicative of whether the normalization + was done. """ measurement = measurement or self.measurement if key == "potential": diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index 46497b19..f13b4780 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -7,7 +7,6 @@ from ..exporters.ecms_exporter import ECMSExporter from ..plotters.ecms_plotter import ECMSPlotter from ..plotters.ms_plotter import STANDARD_COLORS -import json # FIXME: doesn't belong here. class ECMSMeasurement(ECMeasurement, MSMeasurement): @@ -98,8 +97,8 @@ def ecms_calibration_curve( sign! e.g. +4 for O2 by OER and -2 for H2 by HER) tspan_list (list of tspan): THe timespans of steady electrolysis tspan_bg (tspan): The time to use as a background - ax (Axis): The axis on which to plot the ms_calibration curve result. Defaults - to a new axis. + ax (Axis): The axis on which to plot the ms_calibration curve result. + Defaults to a new axis. axes_measurement (list of Axes): The EC-MS plot axes to highlight the ms_calibration on. Defaults to None. diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index 14b7967f..26c6ae58 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -40,7 +40,7 @@ def ms_calibration(self): for cal in self.calibration_list: ms_cal_list = ms_cal_list + getattr(cal, "ms_cal_list", []) for mass, bg in getattr(cal, "signal_bgs", {}).items(): - if not mass in signal_bgs: + if mass not in signal_bgs: signal_bgs[mass] = bg tspan_bg = tspan_bg or getattr(cal, "tspan_bg", None) return MSCalibration(ms_cal_results=ms_cal_list, signal_bgs=signal_bgs) @@ -539,8 +539,8 @@ def gas_flux_calibration( ax (matplotlib axis): the axis on which to indicate what signal is used with a thicker line. Defaults to none - Returns MSCalResult: a ms_calibration result containing the sensitivity factor for - mol at mass + Returns MSCalResult: a ms_calibration result containing the sensitivity factor + for mol at mass """ t, S = measurement.grab_signal(mass, tspan=tspan, t_bg=tspan_bg) if ax: diff --git a/src/ixdat/techniques/spectroelectrochemistry.py b/src/ixdat/techniques/spectroelectrochemistry.py index ec26e3b2..213ebf9c 100644 --- a/src/ixdat/techniques/spectroelectrochemistry.py +++ b/src/ixdat/techniques/spectroelectrochemistry.py @@ -1,9 +1,9 @@ +import numpy as np +from scipy.interpolate import interp1d + from .ec import ECMeasurement -from ..plotters.sec_plotter import SECPlotter from ..spectra import Spectrum from ..data_series import Field, ValueSeries -import numpy as np -from scipy.interpolate import interp1d from ..spectra import SpectrumSeries from ..exporters.sec_exporter import SECExporter from ..plotters.sec_plotter import SECPlotter From 6fe912caced2867ab45dfd5f5cba1b3e5f3b240d Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Thu, 3 Feb 2022 14:16:30 +0000 Subject: [PATCH 100/118] new biologic timestamp form (#33) --- src/ixdat/readers/biologic.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ixdat/readers/biologic.py b/src/ixdat/readers/biologic.py index 6dbd8d5c..0d99947c 100644 --- a/src/ixdat/readers/biologic.py +++ b/src/ixdat/readers/biologic.py @@ -236,8 +236,9 @@ def get_column_unit(column_name): BIOLOGIC_TIMESTAMP_FORMS = ( "%m-%d-%Y %H:%M:%S", # like 01-31-2020 10:32:02 "%m/%d/%Y %H:%M:%S", # like 07/29/2020 10:31:03 - "%m-%d-%Y %H:%M:%S.%f", # (not seen yet) + "%m-%d-%Y %H:%M:%S.%f", # (anticipated) "%m/%d/%Y %H:%M:%S.%f", # like 04/27/2021 11:35:39.227 (EC-Lab v11.34) + "%m/%d/%Y %H.%M.%S", # like 01/31/2022 11.19.17 ) # This tuple contains variable names encountered in .mpt files. The tuple can be used by From f89e66a9bc15936eb7214631be38e492fe98e265 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Thu, 3 Feb 2022 14:33:53 +0000 Subject: [PATCH 101/118] tspan_bg not t_bg (#27) --- src/ixdat/plotters/ms_plotter.py | 4 ++-- src/ixdat/techniques/deconvolution.py | 12 ++++++------ src/ixdat/techniques/ms.py | 18 +++++++++--------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/ixdat/plotters/ms_plotter.py b/src/ixdat/plotters/ms_plotter.py index f215fb94..4036917e 100644 --- a/src/ixdat/plotters/ms_plotter.py +++ b/src/ixdat/plotters/ms_plotter.py @@ -104,7 +104,7 @@ def plot_measurement( t, v = measurement.grab_signal( v_or_v_name, tspan=tspan, - t_bg=tspan_bg, + tspan_bg=tspan_bg, removebackground=removebackground, include_endpoints=False, ) @@ -236,7 +236,7 @@ def plot_vs( t_v, v = measurement.grab_signal( v_name, tspan=tspan, - t_bg=tspan_bg, + tspan_bg=tspan_bg, removebackground=removebackground, include_endpoints=False, ) diff --git a/src/ixdat/techniques/deconvolution.py b/src/ixdat/techniques/deconvolution.py index 74aded7a..2bc7c36e 100644 --- a/src/ixdat/techniques/deconvolution.py +++ b/src/ixdat/techniques/deconvolution.py @@ -21,7 +21,7 @@ def __intit__(self, name, **kwargs): super().__init__(name, **kwargs) def grab_partial_current( - self, signal_name, kernel_obj, tspan=None, t_bg=None, snr=10 + self, signal_name, kernel_obj, tspan=None, tspan_bg=None, snr=10 ): """Return the deconvoluted partial current for a given signal @@ -31,11 +31,11 @@ def grab_partial_current( kernel_obj (Kernel): Kernel object which contains the mass transport parameters tspan (list): Timespan for which the partial current is returned. - t_bg (list): Timespan that corresponds to the background signal. + tspan_bg (list): Timespan that corresponds to the background signal. snr (int): signal-to-noise ratio used for Wiener deconvolution. """ - t_sig, v_sig = self.grab_cal_signal(signal_name, tspan=tspan, t_bg=t_bg) + t_sig, v_sig = self.grab_cal_signal(signal_name, tspan=tspan, tspan_bg=tspan_bg) kernel = kernel_obj.calculate_kernel( dt=t_sig[1] - t_sig[0], duration=t_sig[-1] - t_sig[0] @@ -49,7 +49,7 @@ def grab_partial_current( partial_current = partial_current * sum(kernel) return t_sig, partial_current - def extract_kernel(self, signal_name, cutoff_pot=0, tspan=None, t_bg=None): + def extract_kernel(self, signal_name, cutoff_pot=0, tspan=None, tspan_bg=None): """Extracts a Kernel object from a measurement. Args: @@ -60,11 +60,11 @@ def extract_kernel(self, signal_name, cutoff_pot=0, tspan=None, t_bg=None): impulse. tspan(list): Timespan from which the kernel/impulse response is extracted. - t_bg (list): Timespan that corresponds to the background signal. + tspan_bg (list): Timespan that corresponds to the background signal. """ x_curr, y_curr = self.grab_current(tspan=tspan) x_pot, y_pot = self.grab_potential(tspan=tspan) - x_sig, y_sig = self.grab_signal(signal_name, tspan=tspan, t_bg=t_bg) + x_sig, y_sig = self.grab_signal(signal_name, tspan=tspan, tspan_bg=tspan_bg) if signal_name == "M32": t0 = x_curr[np.argmax(y_pot > cutoff_pot)] # time of impulse diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index 9e2e01ce..163dcb66 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -89,7 +89,7 @@ def grab_signal( self, signal_name, tspan=None, - t_bg=None, + tspan_bg=None, removebackground=False, include_endpoints=False, ): @@ -98,7 +98,7 @@ def grab_signal( Args: signal_name (str): Name of the signal. tspan (list): Timespan for which the signal is returned. - t_bg (list): Timespan that corresponds to the background signal. + tspan_bg (list): Timespan that corresponds to the background signal. If not given, no background is subtracted. removebackground (bool): Whether to remove a pre-set background if available Defaults to False. (Note in grab_flux it defaults to True.) @@ -108,23 +108,23 @@ def grab_signal( signal_name, tspan=tspan, include_endpoints=include_endpoints ) - if t_bg is None: + if tspan_bg is None: if removebackground and signal_name in self.signal_bgs: return time, value - self.signal_bgs[signal_name] return time, value else: - _, bg = self.grab(signal_name, tspan=t_bg) + _, bg = self.grab(signal_name, tspan=tspan_bg) return time, value - np.average(bg) - def grab_cal_signal(self, signal_name, tspan=None, t_bg=None): + def grab_cal_signal(self, signal_name, tspan=None, tspan_bg=None): """Returns a calibrated signal for a given signal name. Only works if calibration dict is not None. Args: signal_name (str): Name of the signal. tspan (list): Timespan for which the signal is returned. - t_bg (list): Timespan that corresponds to the background signal. + tspan_bg (list): Timespan that corresponds to the background signal. If not given, no background is subtracted. """ # TODO: Not final implementation. @@ -133,7 +133,7 @@ def grab_cal_signal(self, signal_name, tspan=None, t_bg=None): print("No calibration dict found.") return - time, value = self.grab_signal(signal_name, tspan=tspan, t_bg=t_bg) + time, value = self.grab_signal(signal_name, tspan=tspan, tspan_bg=tspan_bg) return time, value * self.calibration[signal_name] @@ -170,7 +170,7 @@ def grab_flux( x, y = self.grab_signal( mass, tspan=tspan, - t_bg=tspan_bg, + tspan_bg=tspan_bg, removebackground=removebackground, include_endpoints=include_endpoints, ) @@ -429,7 +429,7 @@ def gas_flux_calibration( Returns MSCalResult: a calibration result containing the sensitivity factor for mol at mass """ - t, S = measurement.grab_signal(mass, tspan=tspan, t_bg=tspan_bg) + t, S = measurement.grab_signal(mass, tspan=tspan, tspan_bg=tspan_bg) if ax: ax.plot(t, S, color=STANDARD_COLORS[mass], linewidth=5) From 7baf0b843ba68487617963f2a7bc56d8d632cbff Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Thu, 3 Feb 2022 14:39:03 +0000 Subject: [PATCH 102/118] Voltammogram not Voltammagram (#23) --- development_scripts/cv_tools_dev.py | 6 ++--- .../reader_testers/test_ivium_reader.py | 4 +-- src/ixdat/techniques/__init__.py | 4 +-- src/ixdat/techniques/cv.py | 26 +++++++++---------- src/ixdat/techniques/ec.py | 8 +++--- src/ixdat/techniques/ec_ms.py | 4 +-- 6 files changed, 26 insertions(+), 26 deletions(-) diff --git a/development_scripts/cv_tools_dev.py b/development_scripts/cv_tools_dev.py index bb13d11e..621ab7c9 100644 --- a/development_scripts/cv_tools_dev.py +++ b/development_scripts/cv_tools_dev.py @@ -7,12 +7,12 @@ from pathlib import Path from matplotlib import pyplot as plt -from ixdat.techniques import CyclicVoltammagram +from ixdat.techniques import CyclicVoltammogram plt.close("all") -stripping_cycle = CyclicVoltammagram.get(1) -base_cycle = CyclicVoltammagram.get(2) +stripping_cycle = CyclicVoltammogram.get(1) +base_cycle = CyclicVoltammogram.get(2) diff = stripping_cycle.diff_with(base_cycle) diff --git a/development_scripts/reader_testers/test_ivium_reader.py b/development_scripts/reader_testers/test_ivium_reader.py index 699e9177..1f87a321 100644 --- a/development_scripts/reader_testers/test_ivium_reader.py +++ b/development_scripts/reader_testers/test_ivium_reader.py @@ -2,7 +2,7 @@ import pandas as pd from matplotlib import pyplot as plt -from ixdat.techniques import CyclicVoltammagram +from ixdat.techniques import CyclicVoltammogram path_to_file = Path.home() / ( "Dropbox/ixdat_resources/test_data/ivium/ivium_test_dataset" @@ -10,7 +10,7 @@ path_to_single_file = path_to_file.parent / (path_to_file.name + "_1") df = pd.read_csv(path_to_single_file, sep=r"\s+", header=1) -meas = CyclicVoltammagram.read(path_to_file, reader="ivium") +meas = CyclicVoltammogram.read(path_to_file, reader="ivium") meas.save() diff --git a/src/ixdat/techniques/__init__.py b/src/ixdat/techniques/__init__.py index c6bdf20d..d8514fac 100644 --- a/src/ixdat/techniques/__init__.py +++ b/src/ixdat/techniques/__init__.py @@ -8,7 +8,7 @@ """ from .ec import ECMeasurement -from .cv import CyclicVoltammagram +from .cv import CyclicVoltammogram from .ms import MSMeasurement from .ec_ms import ECMSMeasurement from .spectroelectrochemistry import SpectroECMeasurement @@ -19,7 +19,7 @@ TECHNIQUE_CLASSES = { "simple": Measurement, "EC": ECMeasurement, - "CV": CyclicVoltammagram, + "CV": CyclicVoltammogram, "MS": MSMeasurement, "EC-MS": ECMSMeasurement, "S-EC": SpectroECMeasurement, diff --git a/src/ixdat/techniques/cv.py b/src/ixdat/techniques/cv.py index 68cc25bd..e72e1832 100644 --- a/src/ixdat/techniques/cv.py +++ b/src/ixdat/techniques/cv.py @@ -9,8 +9,8 @@ ) -class CyclicVoltammagram(ECMeasurement): - """Class for cyclic voltammatry measurements. +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, @@ -29,7 +29,7 @@ def __init__(self, *args, **kwargs): redox = None # see `redefine_cycle` def __getitem__(self, key): - """Given int list or slice key, return a CyclicVoltammagram with those cycles""" + """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: @@ -53,7 +53,7 @@ def cycle(self): try: return self.selector except TypeError: - # FIXME: This is what happens now when a single-cycle CyclicVoltammagram is + # FIXME: This is what happens now when a single-cycle CyclicVoltammogram is # saved and loaded. return ValueSeries( name="cycle", @@ -124,7 +124,7 @@ def redefine_cycle(self, start_potential=None, redox=None): return self.cycle def select_sweep(self, vspan, t_i=None): - """Return a CyclicVoltammagram for while the potential is sweeping through vspan + """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. @@ -218,13 +218,13 @@ def diff_with(self, other, v_list=None, cls=None, v_scan_res=0.001, res_points=1 interpolated onto self's potential and subtracted from self. Args: - other (CyclicVoltammagram): The cyclic voltammagram to subtract from self. + 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 - CyclicVoltammagramDiff. - v_scan_res (float): see CyclicVoltammagram.get_timed_sweeps() - res_points (int): see CyclicVoltammagram.get_timed_sweeps() + CyclicVoltammogramDiff. + v_scan_res (float): see CyclicVoltammogram.get_timed_sweeps() + res_points (int): see CyclicVoltammogram.get_timed_sweeps() """ vseries = self.potential @@ -253,7 +253,7 @@ def diff_with(self, other, v_list=None, cls=None, v_scan_res=0.001, res_points=1 ] if not len(my_sweep_specs) == len(others_sweep_specs): raise BuildError( - "Can only make diff of CyclicVoltammagrams with same number of sweeps." + "Can only make diff of CyclicVoltammograms with same number of sweeps." f"{self} has {my_sweep_specs} and {other} has {others_sweep_specs}." ) @@ -313,14 +313,14 @@ def diff_with(self, other, v_list=None, cls=None, v_scan_res=0.001, res_points=1 diff_as_dict["series_list"] = series_list diff_as_dict["raw_current_names"] = ("raw_current",) - cls = cls or CyclicVoltammagramDiff + cls = cls or CyclicVoltammogramDiff diff = cls.from_dict(diff_as_dict) diff.cv_1 = self diff.cv_2 = other return diff -class CyclicVoltammagramDiff(CyclicVoltammagram): +class CyclicVoltammogramDiff(CyclicVoltammogram): cv_1 = None cv_2 = None @@ -332,7 +332,7 @@ def __init__(self, *args, **kwargs): @property def plotter(self): - """The default plotter for CyclicVoltammagramDiff is CVDiffPlotter""" + """The default plotter for CyclicVoltammogramDiff is CVDiffPlotter""" if not self._plotter: from ..plotters.ec_plotter import CVDiffPlotter diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index d55dd30a..24057726 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -76,7 +76,7 @@ class ECMeasurement(Measurement): 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 `CyclicVoltammagram`. + such as `CyclicVoltammogram`. """ extra_column_attrs = { @@ -649,15 +649,15 @@ def _build_file_number(self): ] = file_number # TODO: better cache'ing. This one gets saved. def as_cv(self): - """Convert self to a CyclicVoltammagram""" - from .cv import CyclicVoltammagram + """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 CyclicVoltammagram.from_dict(self_as_dict) + return CyclicVoltammogram.from_dict(self_as_dict) class ECCalibration: diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index 21628896..6bda091e 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -3,7 +3,7 @@ from ..constants import FARADAY_CONSTANT from .ec import ECMeasurement from .ms import MSMeasurement, MSCalResult -from .cv import CyclicVoltammagram +from .cv import CyclicVoltammogram from ..exporters.ecms_exporter import ECMSExporter from ..plotters.ms_plotter import STANDARD_COLORS from ..db import Saveable # FIXME: doesn't belong here. @@ -190,7 +190,7 @@ def ecms_calibration_curve( return cal -class ECMSCyclicVoltammogram(CyclicVoltammagram, MSMeasurement): +class ECMSCyclicVoltammogram(CyclicVoltammogram, MSMeasurement): """Class for raw EC-MS functionality. Parents: CyclicVoltammogram, MSMeasurement FIXME: Maybe this class should instead inherit from ECMSMeasurement and From 78d9dd9ea23b6c458f0fe87a89afddd31d57ef86 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Thu, 3 Feb 2022 16:09:43 +0000 Subject: [PATCH 103/118] Zilien as EC-MS, EC, or MS (#32) --- .../reader_testers/test_zilien_reader.py | 17 ++++++-- src/ixdat/readers/zilien.py | 40 +++++++++---------- src/ixdat/techniques/ec_ms.py | 22 ++++++++++ 3 files changed, 54 insertions(+), 25 deletions(-) diff --git a/development_scripts/reader_testers/test_zilien_reader.py b/development_scripts/reader_testers/test_zilien_reader.py index 9a42ae9f..cf84c29a 100644 --- a/development_scripts/reader_testers/test_zilien_reader.py +++ b/development_scripts/reader_testers/test_zilien_reader.py @@ -1,9 +1,9 @@ from pathlib import Path from ixdat import Measurement -from ixdat.techniques import MSMeasurement +from ixdat.techniques import MSMeasurement, ECMeasurement -data_dir = Path(r"C:\Users\scott\Dropbox\ixdat_resources\test_data\zilien_with_ec") +data_dir = Path(r"~\Dropbox\ixdat_resources\test_data\zilien_with_ec").expanduser() path_to_file = data_dir / "2021-02-01 17_44_12.tsv" @@ -11,7 +11,7 @@ ecms = Measurement.read(path_to_file, reader="zilien") ecms.plot_measurement() -# This imports it without the EC data: +# This imports it as just an MS measurement. ms = MSMeasurement.read(path_to_file, reader="zilien") ms.plot_measurement() # nice. one panel, no MS :) @@ -22,3 +22,14 @@ ) ecms_2 = ec + ms ecms_2.plot_measurement() + +# This imports it as just an EC measurement +ec_2 = ECMeasurement.read(path_to_file, reader="zilien") +ec_2.plot() + + +# This plots just the EC data from the first EC-MS measurement: + +ecms.ec_plotter.plot_measurement() +ecms.ec_plotter.plot_vs_potential() +ecms.ms_plotter.plot_measurement() diff --git a/src/ixdat/readers/zilien.py b/src/ixdat/readers/zilien.py index f434af7d..f4998f50 100644 --- a/src/ixdat/readers/zilien.py +++ b/src/ixdat/readers/zilien.py @@ -1,12 +1,13 @@ -from pathlib import Path import re -import pandas as pd + import numpy as np +import pandas as pd + +from .ec_ms_pkl import measurement_from_ec_ms_dataset +from .reading_tools import timestamp_string_to_tstamp, FLOAT_MATCH from ..data_series import DataSeries, TimeSeries, ValueSeries, Field -from ..techniques.ec_ms import ECMSMeasurement, MSMeasurement, ECMeasurement +from ..techniques import ECMSMeasurement, MSMeasurement, ECMeasurement, Measurement from ..techniques.ms import MSSpectrum -from .reading_tools import timestamp_string_to_tstamp, FLOAT_MATCH -from .ec_ms_pkl import measurement_from_ec_ms_dataset ZILIEN_TIMESTAMP_FORM = "%Y-%m-%d %H_%M_%S" # like 2021-03-15 18_50_10 @@ -19,32 +20,27 @@ def read(self, path_to_file, cls=None, name=None, **kwargs): TODO: This is a hack using EC_MS to read the .tsv. Will be replaced. """ - cls = cls or ECMSMeasurement + from EC_MS import Zilien_Dataset - ec_ms_dataset = Zilien_Dataset(path_to_file) + if cls is Measurement: + cls = ECMSMeasurement + + if "technique" not in kwargs: + if issubclass(cls, ECMSMeasurement): + kwargs["technique"] = "EC-MS" + elif issubclass(cls, ECMeasurement): + kwargs["technique"] = "EC" + elif issubclass(cls, MSMeasurement): + kwargs["technique"] = "MS" - if not issubclass(cls, ECMeasurement) and issubclass(cls, MSMeasurement): - # This is the case if the user specifically calls read() from an - # MSMeasurement - technique = "MS" - for col in ec_ms_dataset.data_cols.copy(): - # FIXME: EC_MS duplicates and renames Zilien's columns. - # Need a real Zilien reader! - if ec_ms_dataset.data["col_types"][col] == "EC" or col.startswith( - "pot" - ): - ec_ms_dataset.data["data_cols"].remove(col) - del ec_ms_dataset.data[col] - else: - technique = "EC-MS" + ec_ms_dataset = Zilien_Dataset(path_to_file) return measurement_from_ec_ms_dataset( ec_ms_dataset.data, cls=cls, name=name, reader=self, - technique=technique, **kwargs, ) diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index 6bda091e..2cc8250b 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -53,6 +53,8 @@ def __init__(self, **kwargs): ms_kwargs.update(component_measurements=kwargs["component_measurements"]) ECMeasurement.__init__(self, **ec_kwargs) MSMeasurement.__init__(self, **ms_kwargs) + self._ec_plotter = None + self._ms_plotter = None @property def plotter(self): @@ -64,6 +66,26 @@ def plotter(self): return self._plotter + @property + def ec_plotter(self): + """The default plotter for ECMSMeasurement is ECMSPlotter""" + if not self._ec_plotter: + from ..plotters.ec_plotter import ECPlotter + + self._ec_plotter = ECPlotter(measurement=self) + + return self._ec_plotter + + @property + def ms_plotter(self): + """The default plotter for ECMSMeasurement is ECMSPlotter""" + if not self._ms_plotter: + from ..plotters.ms_plotter import MSPlotter + + self._ms_plotter = MSPlotter(measurement=self) + + return self._ms_plotter + @property def exporter(self): """The default plotter for ECMSMeasurement is ECMSExporter""" From 2db8d8fd16e47e62d79d9db40fe238d496ba8a91 Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Thu, 3 Feb 2022 16:39:49 +0000 Subject: [PATCH 104/118] ---- ixdat v0.1.7 ------ --- src/ixdat/__init__.py | 2 +- src/ixdat/measurements.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ixdat/__init__.py b/src/ixdat/__init__.py index 713c332e..41680b4d 100644 --- a/src/ixdat/__init__.py +++ b/src/ixdat/__init__.py @@ -1,6 +1,6 @@ """initialize ixdat, giving top-level access to a few of the important structures """ -__version__ = "0.1.6" +__version__ = "0.1.7" __title__ = "ixdat" __description__ = "The in-situ experimental data tool" __url__ = "https://github.com/ixdat/ixdat" diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index 01999f7b..c0fa4cc2 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -96,6 +96,9 @@ def __init__( # defining these methods here gets them the right docstrings :D self.plot_measurement = self.plotter.plot_measurement self.plot = self.plotter.plot_measurement + # TODO: ... but we need to think a bit more about how to most elegantly and + # dynamically choose plotters (Nice idea from Anna: + # https://github.com/ixdat/ixdat/issues/32) @classmethod def from_dict(cls, obj_as_dict): From 9889bed71e2de00499594bf9511efb6ef4bef9ee Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Sun, 6 Feb 2022 13:09:52 +0000 Subject: [PATCH 105/118] -- v0.1.8 -- fix ECMSCyclicVoltammogram inheritance --- .../reader_testers/test_zilien_reader.py | 7 ++ src/ixdat/__init__.py | 2 +- src/ixdat/techniques/ec_ms.py | 76 +------------------ 3 files changed, 10 insertions(+), 75 deletions(-) diff --git a/development_scripts/reader_testers/test_zilien_reader.py b/development_scripts/reader_testers/test_zilien_reader.py index cf84c29a..2c96658f 100644 --- a/development_scripts/reader_testers/test_zilien_reader.py +++ b/development_scripts/reader_testers/test_zilien_reader.py @@ -9,6 +9,7 @@ # This imports it with the EC data ecms = Measurement.read(path_to_file, reader="zilien") +ecms.calibrate_RE(0) ecms.plot_measurement() # This imports it as just an MS measurement. @@ -33,3 +34,9 @@ ecms.ec_plotter.plot_measurement() ecms.ec_plotter.plot_vs_potential() ecms.ms_plotter.plot_measurement() + +# This plots it as a cyclic voltammagram + +ecms_cv = ecms.as_cv() +ecms_cv.ec_plotter.plot_vs_potential() +ecms_cv.plot() diff --git a/src/ixdat/__init__.py b/src/ixdat/__init__.py index 41680b4d..5b992d88 100644 --- a/src/ixdat/__init__.py +++ b/src/ixdat/__init__.py @@ -1,6 +1,6 @@ """initialize ixdat, giving top-level access to a few of the important structures """ -__version__ = "0.1.7" +__version__ = "0.1.8" __title__ = "ixdat" __description__ = "The in-situ experimental data tool" __url__ = "https://github.com/ixdat/ixdat" diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index 2cc8250b..852a67dd 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -212,82 +212,10 @@ def ecms_calibration_curve( return cal -class ECMSCyclicVoltammogram(CyclicVoltammogram, MSMeasurement): - """Class for raw EC-MS functionality. Parents: CyclicVoltammogram, MSMeasurement - - FIXME: Maybe this class should instead inherit from ECMSMeasurement and - just add the CyclicVoltammogram functionality? +class ECMSCyclicVoltammogram(CyclicVoltammogram, ECMSMeasurement): + """Class for raw EC-MS functionality. Parents: CyclicVoltammogram, ECMSMeasurement """ - extra_column_attrs = { - # FIXME: It would be more elegant if this carried over from both parents - # That might require some custom inheritance definition... - "ecms_meaurements": { - "mass_aliases", - "signal_bgs", - "ec_technique", - "RE_vs_RHE", - "R_Ohm", - "raw_potential_names", - "A_el", - "raw_current_names", - }, - } - - def __init__(self, **kwargs): - """FIXME: Passing the right key-word arguments on is a mess""" - ec_kwargs = { - k: v for k, v in kwargs.items() if k in ECMeasurement.get_all_column_attrs() - } - ec_kwargs.update(series_list=kwargs["series_list"]) - ECMeasurement.__init__(self, **ec_kwargs) - ms_kwargs = { - k: v for k, v in kwargs.items() if k in MSMeasurement.get_all_column_attrs() - } - ms_kwargs.update(series_list=kwargs["series_list"]) - MSMeasurement.__init__(self, **ms_kwargs) - self.plot = self.plotter.plot_vs_potential - # FIXME: only necessary because an ECMSCalibration is not seriealizeable. - self.calibration = kwargs.get("calibration", None) - - @property - def plotter(self): - """The default plotter for ECMSCyclicVoltammogram is ECMSPlotter""" - if not self._plotter: - from ..plotters.ecms_plotter import ECMSPlotter - - self._plotter = ECMSPlotter(measurement=self) - - return self._plotter - - @property - def exporter(self): - """The default plotter for ECMSCyclicVoltammogram is ECMSExporter""" - if not self._exporter: - self._exporter = ECMSExporter(measurement=self) - return self._exporter - - def as_dict(self): - self_as_dict = super().as_dict() - - if self.calibration: - self_as_dict["calibration"] = self.calibration.as_dict() - # FIXME: now that ECMSCalibration should be seriealizeable, it could - # go into extra_column_attrs. But it should be a reference. - return self_as_dict - - @classmethod - def from_dict(cls, obj_as_dict): - """Unpack the ECMSCalibration when initiating from a dict""" - if "calibration" in obj_as_dict: - if isinstance(obj_as_dict["calibration"], dict): - # FIXME: This is a mess - obj_as_dict["calibration"] = ECMSCalibration.from_dict( - obj_as_dict["calibration"] - ) - obj = super(ECMSCyclicVoltammogram, cls).from_dict(obj_as_dict) - return obj - class ECMSCalibration(Saveable): """Class for calibrations useful for ECMSMeasurements From de1c4fa76ecbcbf39697fb509723cae144c54be8 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Wed, 16 Feb 2022 20:28:55 +0000 Subject: [PATCH 106/118] debug spectrum loading: fields as child_attrs --- src/ixdat/db.py | 21 ++++++++++++++++----- src/ixdat/readers/zilien.py | 1 + src/ixdat/spectra.py | 21 ++++++++++++++++----- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/ixdat/db.py b/src/ixdat/db.py index 85490933..b37d328e 100644 --- a/src/ixdat/db.py +++ b/src/ixdat/db.py @@ -61,8 +61,12 @@ def load_obj_data(self, obj): def set_backend(self, backend_name, **db_kwargs): """Change backend to the class given by backend_name initiated with db_kwargs""" - if backend_name in BACKEND_CLASSES: + if not isinstance(backend_name, str): + # Then we assume that it is the backend itself, not the backend name + self.backend = backend_name + elif backend_name in BACKEND_CLASSES: BackendClass = BACKEND_CLASSES[backend_name] + self.backend = BackendClass(**db_kwargs) else: raise NotImplementedError( f"ixdat doesn't recognize db_name = '{backend_name}'. If this is a new" @@ -70,7 +74,7 @@ def set_backend(self, backend_name, **db_kwargs): "constant in ixdat.backends." "Or manually set it directly with DB.backend = " ) - self.backend = BackendClass(**db_kwargs) + return self.backend DB = DataBase() # initate the database. It functions as a global "constant" @@ -399,8 +403,11 @@ def from_dict(cls, obj_as_dict): @classmethod def get(cls, i, backend=None): """Open an object of cls given its id (the table is cls.table_name)""" - backend = backend or DB.backend - return backend.get(cls, i) + old_backend = DB.backend + DB.set_backend(backend or old_backend) + obj = DB.get(cls, i) # gets it from the requested backend. + DB.set_backend(old_backend) + return obj def load_data(self, db=None): """Load the data of the object, if ixdat in its laziness hasn't done so yet""" @@ -411,15 +418,19 @@ def load_data(self, db=None): class PlaceHolderObject: """A tool for ixdat's laziness, instances sit in for Savable objects.""" - def __init__(self, i, cls, backend): + def __init__(self, i, cls, backend=None): """Initiate a PlaceHolderObject with info for loading the real obj when needed Args: i (int): The id (principle key) of the object represented cls (class): Class inheriting from Savable and thus specifiying the table + backend (Backend, optional). by default, placeholders objects must live in + the active backend. This is the case if loaded with get(). """ self.id = i self.cls = cls + if not backend: # + backend = DB.backend if not backend or backend == "none" or backend is database_backends["none"]: raise DataBaseError( f"Can't make a PlaceHolderObject with backend={backend}" diff --git a/src/ixdat/readers/zilien.py b/src/ixdat/readers/zilien.py index 9df515f3..5632784f 100644 --- a/src/ixdat/readers/zilien.py +++ b/src/ixdat/readers/zilien.py @@ -1,6 +1,7 @@ import re import pandas as pd import numpy as np +from pathlib import Path from ..data_series import DataSeries, TimeSeries, ValueSeries, Field from ..techniques import ECMSMeasurement, MSMeasurement, ECMeasurement, Measurement from ..techniques.ms import MSSpectrum diff --git a/src/ixdat/spectra.py b/src/ixdat/spectra.py index f22c4a53..9ff5aad2 100644 --- a/src/ixdat/spectra.py +++ b/src/ixdat/spectra.py @@ -33,6 +33,7 @@ class Spectrum(Saveable): "sample_name", "field_id", } + child_attrs = ["fields"] def __init__( self, @@ -65,9 +66,10 @@ def __init__( self.tstamp = tstamp self.sample_name = sample_name self.reader = reader - self._field = field or PlaceHolderObject( - field_id, cls=Field, backend=self.backend - ) + # Note: the PlaceHolderObject can be initiated without the backend because + # if field_id is provided, then the relevant backend is the active one, + # which PlaceHolderObject uses by default. + self._field = field or PlaceHolderObject(field_id, cls=Field) self.plotter = SpectrumPlotter(spectrum=self) # defining this method here gets it the right docstrings :D @@ -174,6 +176,10 @@ def field(self): self._field = self._field.get_object() return self._field + @property + def fields(self): + return [self.field] + @property def field_id(self): """The id of the field""" @@ -208,8 +214,8 @@ def yseries(self): @property def y(self): - """The y data is the data attribute of the field""" - return self.field.data + """The y data is the one-dimensional data attribute of the field""" + return self.field.data[0] @property def y_name(self): @@ -293,6 +299,11 @@ def x_name(self): """The name of the scanning variable""" return self.xseries.name + @property + def y(self): + """The y data is the multi-dimensional data attribute of the field""" + return self.field.data + def __getitem__(self, key): """Indexing a SpectrumSeries with an int n returns its n'th spectrum""" if isinstance(key, int): From c21e809298554d69c3300521145b7a42b90db95c Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Fri, 18 Feb 2022 11:53:33 +0000 Subject: [PATCH 107/118] fix CyclicVoltammogram --- development_scripts/reader_testers/test_ixdat_csv_reader.py | 4 ++++ src/ixdat/techniques/cv.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/development_scripts/reader_testers/test_ixdat_csv_reader.py b/development_scripts/reader_testers/test_ixdat_csv_reader.py index c98e59d0..c76e26d2 100644 --- a/development_scripts/reader_testers/test_ixdat_csv_reader.py +++ b/development_scripts/reader_testers/test_ixdat_csv_reader.py @@ -14,6 +14,9 @@ "../../test_data/biologic/Pt_poly_cv_CUT.mpt", reader="biologic" ) meas.calibrate_RE(0.715) + + meas.correct_ohmic_drop(R_Ohm=100) + meas.normalize_current(0.196) meas.as_cv().export("test.csv") @@ -21,3 +24,4 @@ meas_loaded = Measurement.read("test.csv", reader="ixdat") meas_loaded.plot() + diff --git a/src/ixdat/techniques/cv.py b/src/ixdat/techniques/cv.py index fdacda91..a9dd9abe 100644 --- a/src/ixdat/techniques/cv.py +++ b/src/ixdat/techniques/cv.py @@ -232,7 +232,7 @@ def diff_with(self, other, v_list=None, cls=None, v_scan_res=0.001, res_points=1 vseries = self.potential tseries = vseries.tseries - series_list = [tseries, self.raw_potential, self.cycle] + series_list = [tseries, self["raw_potential"], self["cycle"]] v_list = v_list or ["current", "raw_current"] if "potential" in v_list: @@ -314,7 +314,6 @@ def diff_with(self, other, v_list=None, cls=None, v_scan_res=0.001, res_points=1 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) @@ -325,6 +324,7 @@ def diff_with(self, other, v_list=None, cls=None, v_scan_res=0.001, res_points=1 class CyclicVoltammogramDiff(CyclicVoltammogram): + default_plotter = CVDiffPlotter cv_1 = None cv_2 = None From de4dada2a1ce9a347b9b3db3a63707659ea25766 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Wed, 9 Mar 2022 14:18:39 +0000 Subject: [PATCH 108/118] aliases in EC_MS pickle importer --- src/ixdat/readers/ec_ms_pkl.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ixdat/readers/ec_ms_pkl.py b/src/ixdat/readers/ec_ms_pkl.py index e3ddf055..8ff9c7aa 100644 --- a/src/ixdat/readers/ec_ms_pkl.py +++ b/src/ixdat/readers/ec_ms_pkl.py @@ -110,12 +110,14 @@ def measurement_from_ec_ms_dataset( ) ) + aliases = {"t": ["time/s"], "raw_potential": ["Ewe/V"], "raw_current": ["I/mA"]} obj_as_dict = dict( name=name, technique=technique or "EC_MS", series_list=cols_list, reader=reader, tstamp=ec_ms_dict["tstamp"], + aliases=aliases ) obj_as_dict.update(kwargs) From 299f7061e189753da533cfd68ed2f488d66b0eb5 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Wed, 9 Mar 2022 17:02:33 +0000 Subject: [PATCH 109/118] implement review outside of src --- README.rst | 6 +++--- TOOLS.rst | 2 +- development_scripts/reader_testers/test_autolab_reader.py | 6 +----- .../reader_testers/test_cinfdata_reader.py | 6 +----- development_scripts/reader_testers/test_ivium_reader.py | 2 ++ .../reader_testers/test_ixdat_csv_reader.py | 2 ++ .../reader_testers/test_msrh_sec_decay_reader.py | 6 +++++- .../reader_testers/test_msrh_sec_reader.py | 6 +++++- .../reader_testers/test_pfeiffer_reader.py | 6 +----- development_scripts/reader_testers/test_zilien_reader.py | 2 ++ .../reader_testers/test_zilien_spectrum_reader.py | 2 ++ docs/source/data-series.rst | 2 +- docs/source/developing.rst | 5 ++--- docs/source/exporter_docs/index.rst | 1 + docs/source/index.rst | 2 +- docs/source/measurement.rst | 7 +++---- docs/source/plotter_docs/index.rst | 2 +- docs/source/reader_docs/index.rst | 2 +- docs/source/technique_docs/ec_ms.rst | 2 +- docs/source/technique_docs/electrochemistry.rst | 8 ++++---- docs/source/technique_docs/mass_spec.rst | 2 +- docs/source/tutorials.rst | 1 - src/ixdat/readers/zilien.py | 3 +++ 23 files changed, 44 insertions(+), 39 deletions(-) diff --git a/README.rst b/README.rst index af839c22..b83842b0 100644 --- a/README.rst +++ b/README.rst @@ -26,7 +26,7 @@ Output: In-situ experimental data made easy -Or rather, than exporting, you can take advantage of ``ixdat``'s powerful analysis +Or rather than exporting, you can take advantage of ``ixdat``'s powerful analysis tools and database backends to be a one-stop tool from messy raw data to public repository accompanying your breakthrough publication and advancing our field. @@ -37,9 +37,9 @@ About Documentation is at https://ixdat.readthedocs.io -In addition to a **pluggable** parser interface for importing your data format, ``ixdat`` it also includes +In addition to a **pluggable** parser interface for importing your data format, ``ixdat`` also includes pluggable exporters and plotters, as well as a database interface. A relational model of experimental data is -thought into every level. +designed into every level. .. list-table:: Techniques and Readers :widths: 20 15 50 diff --git a/TOOLS.rst b/TOOLS.rst index 194bf7e9..f1ade5c6 100644 --- a/TOOLS.rst +++ b/TOOLS.rst @@ -76,7 +76,7 @@ https://docs.pytest.org/en/stable/ sphinx ...... **sphinx** is used to generate the beautiful documentation on -https://ixdat.readthedocs.io from ReStructuredTex and ixdat source code. +https://ixdat.readthedocs.io from ReStructuredText and ixdat source code. To set it up just install sphinx, if you haven't already. In your terminal or Anaconda prompt, type:: $ pip install sphinx diff --git a/development_scripts/reader_testers/test_autolab_reader.py b/development_scripts/reader_testers/test_autolab_reader.py index 7e7ff398..a6d21602 100644 --- a/development_scripts/reader_testers/test_autolab_reader.py +++ b/development_scripts/reader_testers/test_autolab_reader.py @@ -1,9 +1,5 @@ -# -*- coding: utf-8 -*- -""" -Created on Mon Jan 25 22:02:57 2021 +"""For use in development of the autolab reader. Requires access to sample data.""" -@author: scott -""" from pathlib import Path from matplotlib import pyplot as plt diff --git a/development_scripts/reader_testers/test_cinfdata_reader.py b/development_scripts/reader_testers/test_cinfdata_reader.py index c01e9b50..123a28ac 100644 --- a/development_scripts/reader_testers/test_cinfdata_reader.py +++ b/development_scripts/reader_testers/test_cinfdata_reader.py @@ -1,9 +1,5 @@ -# -*- coding: utf-8 -*- -""" -Created on Mon Jan 25 22:02:57 2021 +"""For use in development of the cinfdata reader. Requires access to sample data.""" -@author: scott -""" from pathlib import Path from matplotlib import pyplot as plt diff --git a/development_scripts/reader_testers/test_ivium_reader.py b/development_scripts/reader_testers/test_ivium_reader.py index 85989d9c..27047e65 100644 --- a/development_scripts/reader_testers/test_ivium_reader.py +++ b/development_scripts/reader_testers/test_ivium_reader.py @@ -1,3 +1,5 @@ +"""For use in development of the ivium reader. Requires access to sample data.""" + from pathlib import Path import pandas as pd diff --git a/development_scripts/reader_testers/test_ixdat_csv_reader.py b/development_scripts/reader_testers/test_ixdat_csv_reader.py index c76e26d2..1fce0eeb 100644 --- a/development_scripts/reader_testers/test_ixdat_csv_reader.py +++ b/development_scripts/reader_testers/test_ixdat_csv_reader.py @@ -1,3 +1,5 @@ +"""For use in development of the ixdat .csv reader (and exporter).""" + from ixdat import Measurement diff --git a/development_scripts/reader_testers/test_msrh_sec_decay_reader.py b/development_scripts/reader_testers/test_msrh_sec_decay_reader.py index 79a37fff..10203545 100644 --- a/development_scripts/reader_testers/test_msrh_sec_decay_reader.py +++ b/development_scripts/reader_testers/test_msrh_sec_decay_reader.py @@ -1,4 +1,6 @@ -"""Demonstrate simple importing and plotting SEC decay data""" +"""For use in development of the MSRH SEC reader. Requires access to sample data. +MSRH = molecular science research hub, at Imperial College London. +""" from pathlib import Path from ixdat import Measurement @@ -16,6 +18,8 @@ tstamp=1, reader="msrh_sec_decay", ) +# Suggestion: command-line switching for development scripts. +# https://github.com/ixdat/ixdat/pull/30/files#r810014299 sec_meas.calibrate_RE(RE_vs_RHE=0.26) diff --git a/development_scripts/reader_testers/test_msrh_sec_reader.py b/development_scripts/reader_testers/test_msrh_sec_reader.py index 27e2f324..60b5c57c 100644 --- a/development_scripts/reader_testers/test_msrh_sec_reader.py +++ b/development_scripts/reader_testers/test_msrh_sec_reader.py @@ -1,4 +1,6 @@ -"""Demonstrate simple importing and plotting SEC data""" +"""For use in development of the MSRH SEC reader. Requires access to sample data. +MSRH = molecular science research hub, at Imperial College London. +""" from pathlib import Path from ixdat import Measurement @@ -28,6 +30,8 @@ if True: # test export and reload + # Suggestion: command-line switching for development scripts. + # https://github.com/ixdat/ixdat/pull/30/files#r810014299 export_name = "exported_sec.csv" sec_meas.export(export_name) sec_reloaded = Measurement.read(export_name, reader="ixdat") diff --git a/development_scripts/reader_testers/test_pfeiffer_reader.py b/development_scripts/reader_testers/test_pfeiffer_reader.py index 30fa99f7..76554e67 100644 --- a/development_scripts/reader_testers/test_pfeiffer_reader.py +++ b/development_scripts/reader_testers/test_pfeiffer_reader.py @@ -1,9 +1,5 @@ -# -*- coding: utf-8 -*- -""" -Created on Mon Jan 25 22:02:57 2021 +"""For use in development of the pfeiffer reader. Requires access to sample data.""" -@author: scott -""" from pathlib import Path from matplotlib import pyplot as plt diff --git a/development_scripts/reader_testers/test_zilien_reader.py b/development_scripts/reader_testers/test_zilien_reader.py index 2c96658f..dfc2aae5 100644 --- a/development_scripts/reader_testers/test_zilien_reader.py +++ b/development_scripts/reader_testers/test_zilien_reader.py @@ -1,3 +1,5 @@ +"""For use in development of the zilien reader(s). Requires access to sample data.""" + from pathlib import Path from ixdat import Measurement diff --git a/development_scripts/reader_testers/test_zilien_spectrum_reader.py b/development_scripts/reader_testers/test_zilien_spectrum_reader.py index 77f7dbd6..ff8e4094 100644 --- a/development_scripts/reader_testers/test_zilien_spectrum_reader.py +++ b/development_scripts/reader_testers/test_zilien_spectrum_reader.py @@ -1,3 +1,5 @@ +"""For use in development of zilien spectrum reader. Requires access to sample data.""" + from pathlib import Path from ixdat import Spectrum diff --git a/docs/source/data-series.rst b/docs/source/data-series.rst index 91de16e3..c21eecb5 100644 --- a/docs/source/data-series.rst +++ b/docs/source/data-series.rst @@ -39,7 +39,7 @@ even though truth is preserved by adding ``dt`` to a ``tsteries.tstamp`` and sub series from consecutive measurements. Performing these operations on every series in a measurement set is referred to as building a combined measurement, and is only done when explicitly asked for (f.ex. to export or save the combined measurement). Building -makes new Series rather than muting existing ones. A possible exception to immutability +makes new Series rather than mutating existing ones. A possible exception to immutability may be appending data to use ``ixdat`` on an ongoing measurement. diff --git a/docs/source/developing.rst b/docs/source/developing.rst index 694d57ea..61ba2bde 100644 --- a/docs/source/developing.rst +++ b/docs/source/developing.rst @@ -4,7 +4,7 @@ Developing ixdat ================ -If there's an experimental technique or analysis procedure or database or that ixdat +If there's an experimental technique or analysis procedure or database that ixdat should support and doesn't, it might be because **you** haven't coded it yet. Here are a few resources to help you get started developing ixdat. @@ -95,5 +95,4 @@ Write to us *********** We'd love to know what you're working on and help with any issues developing, even before you make a PR. - -For now, best to reach Soren at sbscott@ic.ac.uk \ No newline at end of file +One great way to do so is through `github discussions `_ \ No newline at end of file diff --git a/docs/source/exporter_docs/index.rst b/docs/source/exporter_docs/index.rst index 64b3c8dd..4b39d151 100644 --- a/docs/source/exporter_docs/index.rst +++ b/docs/source/exporter_docs/index.rst @@ -17,6 +17,7 @@ The ``csv_exporter`` module The ``ec_exporter`` module .......................... +.. _`ec-exporter`: .. automodule:: ixdat.exporters.ec_exporter :members: diff --git a/docs/source/index.rst b/docs/source/index.rst index af5dceee..c7e7c65c 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -30,7 +30,7 @@ Output: In-situ experimental data made easy -Or rather, than exporting, you can take advantage of ``ixdat``'s powerful analysis +Or rather than exporting, you can take advantage of ``ixdat``'s powerful analysis tools and database backends to be a one-stop tool from messy raw data to public repository accompanying your breakthrough publication and advancing our field. diff --git a/docs/source/measurement.rst b/docs/source/measurement.rst index 170f253a..3d46f546 100644 --- a/docs/source/measurement.rst +++ b/docs/source/measurement.rst @@ -5,7 +5,6 @@ The measurement structure The **measurement** (``meas``) is the central object in the pluggable structure of ixdat, and the main interface for user interaction. A measurement is an object of the generalized class -main interface for user interaction. A measurement is an object of the generalized class ``Measurement``, defined in the ``measurements`` module, or an inheriting ***TechniqueMeasurement*** class defined in a module of the ``techniques`` folder (see :ref:`techniques`). @@ -13,7 +12,7 @@ main interface for user interaction. A measurement is an object of the generaliz The general pluggable structure is defined by ``Measurement``, connecting every measurement to a *reader* for importing from text, a *backend* for saving and loading in ``ixdat``, a *plotter* for visualization, and an *exporter* for saving outside of ``ixdat``. -Each TechniqueMeasurement class will likely hav its own default reader, plotter, and +Each TechniqueMeasurement class will likely have its own default reader, plotter, and exporter, while an ``ixdat`` session will typically work with one backend handled by the ``db`` model. @@ -57,7 +56,7 @@ This can also be done straight from ``Measurement``, as follows: Where the row with id=3 of the measurements table represents an electrochemistry measurement. Here the column "technique" in the measurements table specifies which -TechniqueMeasurement class is returned. Here, it for row three of the measurements +TechniqueMeasurement class is returned. For row three of the measurements table, the entry "technique" is "EC", ensuring ``ec_meas`` is an object of type ``ECMeasurement``. @@ -76,7 +75,7 @@ There are several ways of interracting with a measurement's ``data_series``: the measurement. - Indexing a measurement with the name of a data series returns that data series, with any time values tstamp'd at ``meas.tstamp`` -- Most TechniqueMeasureemnts provide attribute-style access to essential DataSeries and +- Most TechniqueMeasureements provide attribute-style access to essential DataSeries and data. For example, ``ECMeasurement`` has properties for ``potential`` and ``current`` series, as well as ``t``, ``v``, and ``j`` for data. - The names of the series are available in ``meas.series_names``. diff --git a/docs/source/plotter_docs/index.rst b/docs/source/plotter_docs/index.rst index 9f31107e..4f3f10c1 100644 --- a/docs/source/plotter_docs/index.rst +++ b/docs/source/plotter_docs/index.rst @@ -2,7 +2,7 @@ Plotters: visualizing ``ixdat`` data ==================================== -Sourece: https://github.com/ixdat/ixdat/tree/user_ready/src/ixdat/plotters +Source: https://github.com/ixdat/ixdat/tree/user_ready/src/ixdat/plotters Basic ----- diff --git a/docs/source/reader_docs/index.rst b/docs/source/reader_docs/index.rst index 9f6f56d1..1c8b3656 100644 --- a/docs/source/reader_docs/index.rst +++ b/docs/source/reader_docs/index.rst @@ -12,7 +12,7 @@ A full list of the readers thus accessible and their names can be viewed by typi Reading .csv files exported by ixdat: The ``IxdatCSVReader`` ------------------------------------------------------------ -``ixdat`` can export measureemnt data in a .csv format with necessary information in the +``ixdat`` can export measurement data in a .csv format with necessary information in the header. See :ref:`exporters`. It can naturally read the data that it exports itself. Exporting and reading, however, may result in loss of raw data (unlike ``save()``). diff --git a/docs/source/technique_docs/ec_ms.rst b/docs/source/technique_docs/ec_ms.rst index 1aa6f78a..f44dd38a 100644 --- a/docs/source/technique_docs/ec_ms.rst +++ b/docs/source/technique_docs/ec_ms.rst @@ -11,7 +11,7 @@ It comes with the :ref:`EC-MS plotter ` which makes EC-MS plots li :width: 600 ``ECMSMeasurement.plot_measurement()``. Data from Trimarco, 2018. -Other than that it doesn't have much but inherits from both ``ECMeasurement`` and ``MSMeasurement``. +Other than that, it doesn't have much, but inherits from both ``ECMeasurement`` and ``MSMeasurement``. An ``ECMSMeasurement`` can be created either by adding an ``ECMeasurement`` and an ``MSMeasurement`` using the ``+`` operator, or by directly importing data using an EC-MS :ref:`reader ` such as "zilien". diff --git a/docs/source/technique_docs/electrochemistry.rst b/docs/source/technique_docs/electrochemistry.rst index 92be4e37..11a72320 100644 --- a/docs/source/technique_docs/electrochemistry.rst +++ b/docs/source/technique_docs/electrochemistry.rst @@ -4,13 +4,13 @@ Electrochemistry ================ The main TechniqueMeasurement class for electrochemistry is the ``ECMeasurement``. -Sublcasses of ``ECMeasurement`` include ``CyclicVoltammagram`` and ``CyclicVoltammagramDiff``. +Subclasses of ``ECMeasurement`` include ``CyclicVoltammagram`` and ``CyclicVoltammagramDiff``. -Direct-current electrochemsitry measurements (``ixdat`` does not yet offer specific +Direct-current electrochemistry measurements (``ixdat`` does not yet offer specific functionality for impedance data) are characterized by the essential quantities being working-electrode current (in loop with the counter electrode) and potential (vs the reference electrode) as a function of time. Either current or potential can be controlled -as the input variable, so the other acts at the response, and it is common to plot +as the input variable, so the other acts as the response, and it is common to plot current vs potential, but in all cases both are tracked or controlled as a function of time. This results in the essential variables ``t`` (time), ``v`` (potential), and ``j`` (current). The main job of ``ECMeasurement`` and subclasses is to give standardized, @@ -18,7 +18,7 @@ convenient, and powerful access to these three variables for data selection, ana and visualization, regardless of which hardware the data was acquired with. The default plotter, :ref:`ECPlotter `, plots these variables. -The default exporter, ECExporter, exports these variables as well as an incrementer for +The default exporter, :ref:`ECPlotter `, exports these variables as well as an incrementer for selecting data, ``cycle``. Electrochemistry is the most thoroughly developed technique in ``ixdat``. For in-depth diff --git a/docs/source/technique_docs/mass_spec.rst b/docs/source/technique_docs/mass_spec.rst index 505c9dd0..00620530 100644 --- a/docs/source/technique_docs/mass_spec.rst +++ b/docs/source/technique_docs/mass_spec.rst @@ -6,7 +6,7 @@ Source: https://github.com/ixdat/ixdat/tree/user_ready/src/ixdat/techniques/ms Mass spectrometry is commonly used in catalysis and electrocatalysis for two different types of data - spectra, where intensity is taken while scanning over m/z, and -mass intensity detection (MID) where the intensity of a small set of m/z values are +multiple ion detection (MID) where the intensity of a small set of m/z values are tracked in time. The main TechniqueMeasurement class for MID data is the ``MSMeasurement``. diff --git a/docs/source/tutorials.rst b/docs/source/tutorials.rst index 9af51e43..8cface99 100644 --- a/docs/source/tutorials.rst +++ b/docs/source/tutorials.rst @@ -40,7 +40,6 @@ It demonstrates, with CO stripping as an example, the following features: - Lining seperate cycles up with respect to potential It reads ixdat-exported data directly from github. -A worked example based on the methods in this tutorial Spectroelectrochemistry diff --git a/src/ixdat/readers/zilien.py b/src/ixdat/readers/zilien.py index 5632784f..03838b2f 100644 --- a/src/ixdat/readers/zilien.py +++ b/src/ixdat/readers/zilien.py @@ -19,6 +19,9 @@ "cycle": ["cycle number"], } +# TODO: When, in the future, Zilien files include the whole EC dataset, remove the +# unflattering example presently in the docs. +# https://github.com/ixdat/ixdat/pull/30/files#r810087496 class ZilienTSVReader: """Class for reading files saved by Spectro Inlets' Zilien software""" From b2a0d869754e01716bfe3ee1bc49f604bde45a9b Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Wed, 9 Mar 2022 17:28:07 +0000 Subject: [PATCH 110/118] physical constants from scipy --- src/ixdat/constants.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/ixdat/constants.py b/src/ixdat/constants.py index 0feb7663..eeb2cf7c 100644 --- a/src/ixdat/constants.py +++ b/src/ixdat/constants.py @@ -1,27 +1,31 @@ -import numpy as np +from scipy import constants as scipy_constants -c = 2.997925e8 # speed of light / (m/s) -qe = 1.60219e-19 # fundamental charge / (C) -h = 6.62620e-34 # planck's constant / (J*s) -hbar = h / (2 * np.pi) # reduced planck's constant / (J*s) -NA = 6.02217e23 # Avogadro's number /(mol) or dimensionless -me = 9.10956e-31 # mass of electron / (kg) -kB = 1.38062e-23 # Boltzman constant / (J/K) +# short-form aliases for a few scipy constants +c = scipy_constants.c # speed of light / (m/s) +qe = scipy_constants.e # fundamental charge / (C) +h = scipy_constants.h # planck's constant / (J*s) +hbar = scipy_constants.hbar # reduced planck's constant / (J*s) +NA = scipy_constants.N_A # Avogadro's number /(mol) or dimensionless +me = scipy_constants.m_e # mass of electron / (kg) +kB = scipy_constants.k # Boltzman constant / (J/K) +u0 = scipy_constants.mu_0 # permeability of free space / (J*s^2/(m*C^2)) +e0 = scipy_constants.epsilon_0 # permittivity of free space / (C^2/(J*m)) +R = scipy_constants.R # gas constant / (J/(mol*K)) -u0 = 4 * np.pi * 1e-7 # permeability of free space / (J*s^2/(m*C^2)) -e0 = 1 / (u0 * c ** 2) # permittivity of free space / (C^2/(J*m)) - -R = NA * kB # gas constant / (J/(mol*K)) #NA in /mol -amu = 1e-3 / NA # atomic mass unit / (kg) # amu=1g/NA #NA dimensionless +# a few extra derived constants +amu = 1e-3 / NA # atomic mass unit / (kg) # amu=(1g/mol)/NA Far = NA * qe # Faraday's constant, C/mol +# long-form aliases FARADAY_CONSTANT = Far AVOGADROS_CONSTANT = NA BOLTZMAN_CONSTANT = kB +# standard conditions STANDARD_TEMPERATURE = 298.15 # Standard temperature of 25 C in [K] STANDARD_PRESSURE = 1e5 # Standard pressure of 1 bar in [Pa] +# molecule properties (should probably come from elsewhere). DYNAMIC_VISCOSITIES = { "O2": 2.07e-05, "N2": 1.79e-05, From 0087cd0ee4982d33e70afa9473bf6ac0d49557de Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Wed, 9 Mar 2022 17:35:46 +0000 Subject: [PATCH 111/118] implement review of db and data_series --- src/ixdat/data_series.py | 4 ++++ src/ixdat/db.py | 32 ++++++++++++++++---------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/ixdat/data_series.py b/src/ixdat/data_series.py index a176ee44..519f49b5 100644 --- a/src/ixdat/data_series.py +++ b/src/ixdat/data_series.py @@ -180,6 +180,10 @@ def data(self): @property def tstamp(self): + """The unix time corresponding to t=0 for the time-resolved axis of the Field + + The timestamp of a Field is the timestamp of its TimeSeries or ValueSeries + """ for s in self.axes_series: if isinstance(s, (ValueSeries, TimeSeries)): return s.tstamp diff --git a/src/ixdat/db.py b/src/ixdat/db.py index b37d328e..6d5e41b4 100644 --- a/src/ixdat/db.py +++ b/src/ixdat/db.py @@ -16,7 +16,7 @@ managed attribute, from an object in memory. `load` and `get` convention holds vertically - i.e. the Backend, the DataBase, - up through the Savable parent class for all ixdat classes corresponding to + up through the Saveable parent class for all ixdat classes corresponding to database tables have `load` and `get` methods which call downwards. TODO. see: https://github.com/ixdat/ixdat/pull/1#discussion_r546400793 """ @@ -27,7 +27,7 @@ class DataBase: - """This class is a kind of middle-man between a Backend and a Savealbe class + """This class is a kind of middle-man between a Backend and a Saveable class The reason for a middle man here is that it enables different databases (backends) to be switched between and kept track of in a single ixdat session. @@ -42,21 +42,21 @@ def __init__(self, backend=None): self.new_object_backend = "none" def save(self, obj): - """Save a Savable object with the backend""" + """Save a Saveable object with the backend""" return self.backend.save(obj) def get(self, cls, i, backend=None): - """Select and return object of Savable class cls with id=i from the backend""" + """Select and return object of Saveable class cls with id=i from the backend""" backend = backend or self.backend obj = backend.get(cls, i) # obj will already have obj.id = i and obj.backend = self.backend from backend return obj def load(self, cls, name): - """Select and return object of Savable class cls with name=name from backend""" + """Select and return object of Saveable class cls with name=name from backend""" def load_obj_data(self, obj): - """Load and return the numerical data (obj.data) for a Savable object""" + """Load and return the numerical data (obj.data) for a Saveable object""" return self.backend.load_obj_data(obj) def set_backend(self, backend_name, **db_kwargs): @@ -154,7 +154,7 @@ class Saveable: child_attrs = None # THIS SHOULD BE OVERWRITTEN IN CLASSES WITH DATA REFERENCES def __init__(self, backend=None, **self_as_dict): - """Initialize a Savable object from its dictionary serialization + """Initialize a Saveable object from its dictionary serialization This is the default behavior, and should be overwritten using an argument-free call to super().__init__() in inheriting classes. @@ -196,7 +196,7 @@ def short_identity(self): FIXME: The overloaded return here is annoying and dangerous, but necessary for `Measurement.from_dict(m.as_dict())` to work as a copy, since the call to `fill_object_list` has to specify where the objects represented by - PlaceHolderObjects live. Note that calling save() on a Savable object will + PlaceHolderObjects live. Note that calling save() on a Saveable object will turn the backends into DB.backend, so this will only give id's when saving. This is (usually) sufficient to tell if two objects refer to the same thing, when used together with the class attribute table_name @@ -215,7 +215,7 @@ def full_identity(self): @property def backend(self): - """The backend the Savable object was loaded from or last saved to.""" + """The backend the Saveable object was loaded from or last saved to.""" if not self._backend: self._backend = database_backends["none"] return self._backend @@ -243,11 +243,11 @@ def backend_type(self): return self.backend.backend_type def set_id(self, i): - """Backends set obj.id here after loading/saving a Savable obj""" + """Backends set obj.id here after loading/saving a Saveable obj""" self._id = i def set_backend(self, backend): - """Backends set obj.backend here after loading/saving a Savable obj""" + """Backends set obj.backend here after loading/saving a Saveable obj""" self.backend = backend def get_main_dict(self, exclude=None): @@ -416,15 +416,15 @@ def load_data(self, db=None): class PlaceHolderObject: - """A tool for ixdat's laziness, instances sit in for Savable objects.""" + """A tool for ixdat's laziness, instances sit in for Saveable objects.""" def __init__(self, i, cls, backend=None): """Initiate a PlaceHolderObject with info for loading the real obj when needed Args: i (int): The id (principle key) of the object represented - cls (class): Class inheriting from Savable and thus specifiying the table - backend (Backend, optional). by default, placeholders objects must live in + cls (class): Class inheriting from Saveable and thus specifiying the table + backend (Backend, optional): by default, placeholders objects must live in the active backend. This is the case if loaded with get(). """ self.id = i @@ -459,7 +459,7 @@ def fill_object_list(object_list, obj_ids, cls=None): obj_ids (list of ints or None): The id's of objects to ensure are in the list. Any id in obj_ids not already represented in object_list is added to the list as a PlaceHolderObject - cls (Savable class): the class remembered by any PlaceHolderObjects + cls (Saveable class): the class remembered by any PlaceHolderObjects added to the object_list, so that eventually the right object will be loaded. Must be specified if object_list is empty. """ @@ -480,7 +480,7 @@ def fill_object_list(object_list, obj_ids, cls=None): def with_memory(function): - """Decorator for saving all new Savable objects initiated in the memory backend""" + """Decorator for saving all new Saveable objects initiated in the memory backend""" def function_with_memory(*args, **kwargs): DB.new_object_backend = "memory" From 4700a7d9473aab3cca9ae70a24ea781d98e4ff5d Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Fri, 11 Mar 2022 09:46:04 +0000 Subject: [PATCH 112/118] implement review of exporters --- .../reader_testers/test_ixdat_csv_reader.py | 4 +- src/ixdat/exporters/csv_exporter.py | 43 +++++++++++-------- src/ixdat/exporters/ec_exporter.py | 2 +- src/ixdat/exporters/ecms_exporter.py | 2 +- src/ixdat/exporters/sec_exporter.py | 2 +- src/ixdat/exporters/spectrum_exporter.py | 16 ++++--- 6 files changed, 42 insertions(+), 27 deletions(-) diff --git a/development_scripts/reader_testers/test_ixdat_csv_reader.py b/development_scripts/reader_testers/test_ixdat_csv_reader.py index 1fce0eeb..c664854b 100644 --- a/development_scripts/reader_testers/test_ixdat_csv_reader.py +++ b/development_scripts/reader_testers/test_ixdat_csv_reader.py @@ -21,7 +21,9 @@ meas.normalize_current(0.196) - meas.as_cv().export("test.csv") + cv = meas.as_cv() + + cv.export("test.csv") meas_loaded = Measurement.read("test.csv", reader="ixdat") diff --git a/src/ixdat/exporters/csv_exporter.py b/src/ixdat/exporters/csv_exporter.py index f1dbad20..e70bdf5d 100644 --- a/src/ixdat/exporters/csv_exporter.py +++ b/src/ixdat/exporters/csv_exporter.py @@ -6,7 +6,7 @@ class CSVExporter: """The default exporter, which writes delimited measurement data row-wise to file""" - default_v_list = None # This will typically be overwritten by inheriting Exporters + default_export_columns = None # Typically overwritten by inheriting Exporters """The names of the value series to export by default.""" aliases = None # This will typically be overwritten by inheriting Exporters """The aliases, needed for techniques with essential series that get renamed.""" @@ -20,7 +20,7 @@ def __init__(self, measurement=None, delim=",\t"): self.columns_data = None self.path_to_file = None - def export(self, path_to_file=None, measurement=None, v_list=None, tspan=None): + def export(self, path_to_file=None, measurement=None, columns=None, tspan=None): """Export a given measurement to a specified file. To improve flexibility with inheritance, this method allocates its work to: @@ -31,22 +31,24 @@ def export(self, path_to_file=None, measurement=None, v_list=None, tspan=None): Args: measurement (Measurement): The measurement to export. Defaults to self.measurement. + TODO: remove this kwarg. See conversation here: + https://github.com/ixdat/ixdat/pull/30/files#r810926968 path_to_file (Path): The path to the file to write. If it has no suffix, - a .csv suffix is appended. Defaults to "{measurement.name}.csv" - v_list (list of str): The names of the data series to include. Defaults in + a .csv suffix is appended. Defaults to f"{measurement.name}.csv" + columns (list of str): The names of the data series to include. Defaults in CSVExporter to all VSeries and TSeries in the measurement. This default may be overwritten in inheriting exporters. tspan (timespan): The timespan to include in the file, defaults to all of it """ measurement = measurement or self.measurement if not path_to_file: - path_to_file = input("enter name of file to export.") + path_to_file = f"{measurement.name}.csv" if isinstance(path_to_file, str): path_to_file = Path(path_to_file) if not path_to_file.suffix: path_to_file = path_to_file.with_suffix(".csv") self.path_to_file = path_to_file - self.prepare_header_and_data(measurement, v_list, tspan) + self.prepare_header_and_data(measurement, columns, tspan) self.prepare_column_header() self.write_header() self.write_data() @@ -60,16 +62,21 @@ def prepare_header_and_data(self, measurement, v_list, tspan): tspan (timespan): The timespan of the data to include in the export """ columns_data = {} - s_list = [] - v_list = v_list or self.default_v_list or list(measurement.value_names) + # list of the value names to export: + v_list = v_list or self.default_export_columns or list(measurement.value_names) + s_list = [] # list of the series names to export. + # s_list will also include names of TimeSeries. - timecols = {} + timecols = {} # Will be {time_name: value_names}, for the header. for v_name in v_list: + # Collect data and names for each ValueSeries and TimeSeries t_name = measurement[v_name].tseries.name t, v = measurement.grab(v_name, tspan=tspan) if t_name in timecols: + # We've already collected the data for this time column timecols[t_name].append(v_name) else: + # New time column. Collect its data and add it to the timecols. columns_data[t_name] = t s_list.append(t_name) timecols[t_name] = [v_name] @@ -80,7 +87,11 @@ def prepare_header_and_data(self, measurement, v_list, tspan): for attr in ["name", "technique", "tstamp", "backend_name", "id"]: line = f"{attr} = {getattr(measurement, attr)}\n" header_lines.append(line) + # TODO: This should be more automated... the exporter should put all + # the appropriate metadata attributes of the object, read from its + # table definition, in the header. for t_name, v_names in timecols.items(): + # Header includes a line for each time column stating which values use it: line = ( f"timecol '{t_name}' for: " + " and ".join([f"'{v_name}'" for v_name in v_names]) @@ -88,6 +99,7 @@ def prepare_header_and_data(self, measurement, v_list, tspan): ) header_lines.append(line) if self.aliases: + # For now, aliases is nice after the timecol lines. But see the to-do above. aliases_line = f"aliases = {json.dumps(self.aliases)}\n" header_lines.append(aliases_line) self.header_lines = header_lines @@ -100,10 +112,7 @@ def prepare_column_header(self): self.header_lines.append(f"N_header_lines = {N_header_lines}\n") self.header_lines.append("\n") - col_header_line = ( - "".join([s_name + self.delim for s_name in self.s_list])[: -len(self.delim)] - + "\n" - ) + col_header_line = self.delim.join(self.s_list) + "\n" self.header_lines.append(col_header_line) def write_header(self): @@ -115,14 +124,14 @@ def write_data(self): """Write data to the file one line at a time.""" max_length = max([len(data) for data in self.columns_data.values()]) for n in range(max_length): - line = "" + data_strings = [] for s_name in self.s_list: if len(self.columns_data[s_name]) > n: # Then there's more data to write for this series - line = line + str(self.columns_data[s_name][n]) + self.delim + data_strings.append(str(self.columns_data[s_name][n])) else: # Then all this series is written. Just leave space. - line = line + self.delim - line = line + "\n" + data_strings.append("") + line = self.delim.join(data_strings) + "\n" with open(self.path_to_file, "a") as f: f.write(line) diff --git a/src/ixdat/exporters/ec_exporter.py b/src/ixdat/exporters/ec_exporter.py index d6864501..d6f14cdf 100644 --- a/src/ixdat/exporters/ec_exporter.py +++ b/src/ixdat/exporters/ec_exporter.py @@ -5,7 +5,7 @@ class ECExporter(CSVExporter): """A CSVExporter that by default exports potential, current, and selector""" @property - def default_v_list(self): + def default_export_columns(self): """The default v_list for ECExporter is V_str, J_str, and sel_str""" return [ # self.measurement.t_name, diff --git a/src/ixdat/exporters/ecms_exporter.py b/src/ixdat/exporters/ecms_exporter.py index b24f2bd9..c8d33897 100644 --- a/src/ixdat/exporters/ecms_exporter.py +++ b/src/ixdat/exporters/ecms_exporter.py @@ -6,7 +6,7 @@ class ECMSExporter(CSVExporter): """A CSVExporter that by default exports potential, current, selector, and all MID""" @property - def default_v_list(self): + def default_export_columns(self): """The default v_list for ECExporter is V_str, J_str, and sel_str""" v_list = ( ECExporter(measurement=self.measurement).default_v_list diff --git a/src/ixdat/exporters/sec_exporter.py b/src/ixdat/exporters/sec_exporter.py index d8043523..689227d3 100644 --- a/src/ixdat/exporters/sec_exporter.py +++ b/src/ixdat/exporters/sec_exporter.py @@ -32,7 +32,7 @@ def spectra_exporter(self): return self._spectra_exporter @property - def default_v_list(self): + def default_export_columns(self): """The default v_list for SECExporter is that from EC and tracked wavelengths""" v_list = ( ECExporter(measurement=self.measurement).default_v_list diff --git a/src/ixdat/exporters/spectrum_exporter.py b/src/ixdat/exporters/spectrum_exporter.py index dfc98c7b..427e7ed1 100644 --- a/src/ixdat/exporters/spectrum_exporter.py +++ b/src/ixdat/exporters/spectrum_exporter.py @@ -22,6 +22,8 @@ def export(self, spectrum, path_to_file): Args: spectrum (Spectrum): The spectrum to export if different from self.spectrum + TODO: remove this kwarg. See conversation here: + https://github.com/ixdat/ixdat/pull/30/files#r810926968 path_to_file (str or Path): The path of the file to export to. Note that if a file already exists with this path, it will be overwritten. """ @@ -36,8 +38,6 @@ def export(self, spectrum, path_to_file): N_header_lines = len(header_lines) + 3 header_lines.append(f"N_header_lines = {N_header_lines}\n") header_lines.append("\n") - # header_lines.append("".join([(key + self.delim) for key in df.keys()])) - df.to_csv(path_to_file, index=False, sep=self.delim) with open(path_to_file, "w") as f: f.writelines(header_lines) @@ -63,10 +63,13 @@ def __init__(self, spectrum_series, delim=","): self.delim = delim def export(self, spectrum_series=None, path_to_file=None, spectra_as_rows=True): - """Export spectrum to path_to_file. + """Export spectrum series to path_to_file. Args: - spectrum (Spectrum): The spectrum to export if different from self.spectrum + spectrum_series (Spectrum): The spectrum_series to export if different from + self.spectrum_series + TODO: remove this kwarg. See conversation here: + https://github.com/ixdat/ixdat/pull/30/files#r810926968 path_to_file (str or Path): The path of the file to export to. Note that if a file already exists with this path, it will be overwritten. spectra_as_rows (bool): This specifies the orientation of the data exported. @@ -89,6 +92,9 @@ def export(self, spectrum_series=None, path_to_file=None, spectra_as_rows=True): line = f"{attr} = {getattr(spectrum_series, attr)}\n" header_lines.append(line) + N_header_lines = len(header_lines) + 3 + header_lines.append(f"N_header_lines = {N_header_lines}\n") + header_lines.append( f"values are y='{field.name}' with units [{field.unit_name}]\n" ) @@ -116,8 +122,6 @@ def export(self, spectrum_series=None, path_to_file=None, spectra_as_rows=True): f"first column is x='{xseries.name}' with units [{xseries.unit_name}]\n" ) - N_header_lines = len(header_lines) + 3 - header_lines.append(f"N_header_lines = {N_header_lines}\n") header_lines.append("\n") with open(path_to_file, "w") as f: From 0276f21ef6a0ef7c70c3f6adf07a3b8b30ff4b77 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Sun, 13 Mar 2022 10:32:26 +0000 Subject: [PATCH 113/118] implement review of measurement.py --- src/ixdat/measurements.py | 54 +++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index c2d4a3f9..c6c95ac2 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -44,7 +44,7 @@ class Measurement(Saveable): } extra_linkers = { "component_measurements": ("measurements", "m_ids"), - "measurement_calibrations": ("ms_calibration", "c_ids"), + "measurement_calibrations": ("calibrations", "c_ids"), "measurement_series": ("data_series", "s_ids"), } child_attrs = ["component_measurements", "calibration_list", "series_list"] @@ -291,7 +291,7 @@ def from_component_measurements( sort (bool): Whether to sort the series according to time kwargs: key-word arguments are added to the dictionary for cls.from_dict() - Returns cls: a Measurement object of the + Returns cls: the combined measurement. """ # First prepare everything but the series_list in the object dictionary @@ -489,11 +489,11 @@ def reverse_aliases(self): """{series_name: standard_names} indicating how raw data can be accessed""" rev_aliases = {} for name, other_names in self.aliases.items(): - for n in other_names: - if n in rev_aliases: - rev_aliases[n].append(name) + for other_name in other_names: + if other_name in rev_aliases: + rev_aliases[other_name].append(name) else: - rev_aliases[n] = [name] + rev_aliases[other_name] = [name] return rev_aliases def get_series_names(self, key): @@ -511,9 +511,9 @@ def __getitem__(self, key): 2. find or build the desired data series by the first possible of: A. Check if `key` corresponds to a method in `series_constructors`. If so, build the data series with that method. - B. Check if the `ms_calibration`'s `calibrate_series` returns a data series + B. Check if the `calibration`'s `calibrate_series` returns a data series for `key` given the data in this measurement. (Note that the - `ms_calibration` will typically start with raw data looked C, below.) + `calibration` will typically start with raw data looked C, below.) C. Generate a list of data series and append them: i. Check if `key` is in `aliases`. If so, append all the data series returned for each key in `aliases[key]`. @@ -537,16 +537,16 @@ def __getitem__(self, key): >>> ec_meas["raw_potential"] # first lookup, explained below ValueSeries("Ewe/V", ...) >>> ec_meas.calibrate_RE(RE_vs_RHE=0.7) - >>> ec_meas["potential"] # second lookup, explained below + >>> ec_meas["potential"] # second lookup, explained below ValueSeries("U_{RHE} / [V]", ...) - The first lookup, with `key="raw_potential"`, (1) checks for "raw_potential" in the cache, doesn't find it; then (2A) checks in - `series_constructors`, doesn't find it; (2B) asks the ms_calibration for + `series_constructors`, doesn't find it; (2B) asks the calibration for "raw_potential" and doesn't get anything back; and finally (2Ci) checks `aliases` for raw potential where it finds that "raw_potential" is called "Ewe/V". Then it looks up again, this time with `key="Ewe/V"`, which it doesn't - find in (1) the cache, (2A) `series_consturctors`, (2B) the ms_calibration, or + find in (1) the cache, (2A) `series_consturctors`, (2B) the calibration, or (2Ci) `aliases`, but does find in (2Cii) `series_list`. There is only one data series named "Ewe/V" so no appending is necessary, but it does ensure that the series has the measurement's `tstamp` before cache'ing and returning it. @@ -555,13 +555,13 @@ def __getitem__(self, key): returns it. - The second lookup, with `key="potential"`, (1) checks for "potential" in the cache, doesn't find it; then (2A) checks in `series_constructors`, - doesn't find it; and then (2B) asks the ms_calibration for "potential". The - ms_calibration knows that when asked for "potential" it should look for + doesn't find it; and then (2B) asks the calibration for "potential". The + calibration knows that when asked for "potential" it should look for "raw_potential" and add `RE_vs_RHE`. So it does a lookup with - `key="raw_potential"` and (1) finds it in the cache. The ms_calibration does + `key="raw_potential"` and (1) finds it in the cache. The calibration does the math and returns a new data series for the calibrated potential, bringing us back to the original lookup. The data series returned by the - ms_calibration is then (3) cached and returned to the user. + calibration is then (3) cached and returned to the user. Note that, if the user had not looked up "raw_potential" before looking up "potential", "raw_potential" would not have been in the cache and the first @@ -591,8 +591,8 @@ def get_series(self, key): See more detailed documentation under `__getitem__`, for which this is a helper method. This method (A) looks for a method for `key` in the measurement's - `series_constructors`; (B) requests its `ms_calibration` for `key`; and if those - fails appends the data series that either (Ci) are returned by looking up the + `series_constructors`; (B) requests its `calibration` for `key`; and if those + fail appends the data series that either (Ci) are returned by looking up the key's `aliases` or (Cii) have `key` as their name; and finally (D) check if the user was using a key with a suffix. @@ -608,7 +608,7 @@ def get_series(self, key): # B for calibration in self.calibrations: series = calibration.calibrate_series(key, measurement=self) - # ^ the ms_calibration will call __getitem__ with the name of the + # ^ the calibration will call __getitem__ with the name of the # corresponding raw data and return a new series with calibrated data # if possible. Otherwise it will return None. if series: @@ -652,8 +652,8 @@ def replace_series(self, series_name, new_series=None): """Remove an existing series, add a series to the measurement, or both. FIXME: This will not appear to change the series for the user if the - measurement's ms_calibration returns something for ´series_name´, since - __getitem__ asks the ms_calibration before looking in series_list. + measurement's calibration returns something for ´series_name´, since + __getitem__ asks the calibration before looking in series_list. Args: series_name (str): The name of a series. If the measurement has (raw) data @@ -704,7 +704,7 @@ def grab(self, item, tspan=None, include_endpoints=False, tspan_bg=None): TODO: option to specifiy desired units Typical usage:: - t, v = measurement.grab(potential, tspan=[0, 100]) + t, v = measurement.grab("potential", tspan=[0, 100]) Args: item (str): The name of the DataSeries to grab data for @@ -1190,8 +1190,8 @@ def join(self, other, join_on=None): Args: other (Measurement): a second measurement to join to self join_on (str or tuple): Either a string, if the value to join on is called - the same thing in both measurements, or a tuple of two strings if it is - not. + the same thing in both measurements, or a tuple of two strings where + the first is the name of the variable in self and the second in other. The variable described by join_on must be monotonically increasing in both measurements. """ @@ -1212,9 +1212,9 @@ def __init__(self, *, name=None, technique=None, tstamp=None, measurement=None): """Initiate a Calibration Args: - name (str): The name of the ms_calibration - technique (str): The technique of the ms_calibration - tstamp (float): The time at which the ms_calibration took place or is valid + name (str): The name of the calibration + technique (str): The technique of the calibration + tstamp (float): The time at which the calibration took place or is valid measurement (Measurement): Optional. A measurement to calibrate by default. """ super().__init__() @@ -1261,7 +1261,7 @@ def read(cls, path_to_file): return cls.from_dict(obj_as_dict) def calibrate_series(self, key, measurement=None): - """This should be overwritten in real ms_calibration classes. + """This should be overwritten in real calibration classes. FIXME: Add more documentation about how to write this in inheriting classes. """ From 9e9e39bd914c5ce8a122087ae181f37853a6cb30 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Sun, 13 Mar 2022 11:17:49 +0000 Subject: [PATCH 114/118] implement review of plotters --- src/ixdat/plotters/base_mpl_plotter.py | 7 ++++++ src/ixdat/plotters/ec_plotter.py | 15 +++++++++++++ src/ixdat/plotters/ecms_plotter.py | 30 +++++++++----------------- src/ixdat/plotters/ms_plotter.py | 13 ++++++++--- src/ixdat/plotters/sec_plotter.py | 6 ++++-- src/ixdat/plotters/spectrum_plotter.py | 2 ++ 6 files changed, 48 insertions(+), 25 deletions(-) diff --git a/src/ixdat/plotters/base_mpl_plotter.py b/src/ixdat/plotters/base_mpl_plotter.py index 01ac61e5..2e417657 100644 --- a/src/ixdat/plotters/base_mpl_plotter.py +++ b/src/ixdat/plotters/base_mpl_plotter.py @@ -1,8 +1,12 @@ +"""Base class for plotters using matplotlib""" + from matplotlib import pyplot as plt from matplotlib import gridspec class MPLPlotter: + """Base class for plotters based on matplotlib. Has methods for making mpl axes.""" + def new_ax(self, xlabel=None, ylabel=None): """Return a new matplotlib axis optionally with the given x and y labels""" fig, ax = plt.subplots() @@ -15,6 +19,9 @@ def new_ax(self, xlabel=None, ylabel=None): def new_two_panel_axes(self, n_bottom=1, n_top=1, emphasis="top"): """Return the axes handles for a bottom and top panel. + TODO: maybe fix order of axes returned. + see https://github.com/ixdat/ixdat/pull/30/files#r811198719 + Args: n_top (int): 1 for a single y-axis, 2 for left and right y-axes on top panel n_bottom (int): 1 for a single y-axis, 2 for left and right y-axes on bottom diff --git a/src/ixdat/plotters/ec_plotter.py b/src/ixdat/plotters/ec_plotter.py index a8cf4f11..0f0b84ca 100644 --- a/src/ixdat/plotters/ec_plotter.py +++ b/src/ixdat/plotters/ec_plotter.py @@ -1,3 +1,5 @@ +"""Plotter for Electrochemistry""" + import numpy as np from .base_mpl_plotter import MPLPlotter from .plotting_tools import color_axis @@ -156,7 +158,13 @@ def __init__(self, measurement=None): self.measurement = measurement def plot(self, measurement=None, ax=None): + """Plot the two cycles of the CVDiff measurement and fill in the areas between + + example: https://ixdat.readthedocs.io/en/latest/_images/cv_diff.svg + """ measurement = measurement or self.measurement + # FIXME: This is probably the wrong use of plotter functions. + # see https://github.com/ixdat/ixdat/pull/30/files#r810926968 ax = ECPlotter.plot_vs_potential( self, measurement=measurement.cv_1, axes=ax, color="g" ) @@ -184,12 +192,19 @@ def plot(self, measurement=None, ax=None): return ax def plot_measurement(self, measurement=None, axes=None, **kwargs): + """Plot the difference between the two cv's vs time""" measurement = measurement or self.measurement + # FIXME: not correct useage of return ECPlotter.plot_measurement( self, measurement=measurement, axes=axes, **kwargs ) def plot_diff(self, measurement=None, tspan=None, ax=None): + """Plot the difference between the two cv's vs potential. + + The trace is solid where the current in cv_2 is greater than cv_1 in the anodic + scan or the current cv_2 is more negative than cv_1 in the cathodic scan. + """ measurement = measurement or self.measurement t, v = measurement.grab("potential", tspan=tspan, include_endpoints=False) j_diff = measurement.grab_for_t("current", t) diff --git a/src/ixdat/plotters/ecms_plotter.py b/src/ixdat/plotters/ecms_plotter.py index cee379d1..7cb0dd0a 100644 --- a/src/ixdat/plotters/ecms_plotter.py +++ b/src/ixdat/plotters/ecms_plotter.py @@ -7,7 +7,7 @@ class ECMSPlotter(MPLPlotter): """A matplotlib plotter for EC-MS measurements.""" def __init__(self, measurement=None): - """Initiate the ECMSPlotter with its default Meausurement to plot""" + """Initiate the ECMSPlotter with its default Measurement to plot""" self.measurement = measurement self.ec_plotter = ECPlotter(measurement=measurement) self.ms_plotter = MSPlotter(measurement=measurement) @@ -23,7 +23,7 @@ def plot_measurement( mol_lists=None, tspan=None, tspan_bg=None, - removebackground=None, + remove_background=None, unit=None, V_str=None, # TODO: Depreciate, replace with v_name, j_name J_str=None, @@ -38,13 +38,8 @@ def plot_measurement( Allocates tasks to ECPlotter.plot_measurement() and MSPlotter.plot_measurement() - TODO: add all functionality in the legendary plot_experiment() in EC_MS.Plotting - - variable subplot sizing (emphasizing EC or MS) - - plotting of calibrated data (mol_list instead of mass_list) - - units! - Args: - measurement (ECMSMeasurement): defaults to the measurement to which the + measurement (ECMSMeasurement): Defaults to the measurement to which the plotter is bound (self.measurement) axes (list of three matplotlib axes): axes[0] plots the MID data, axes[1] the variable given by V_str (potential), and axes[2] the @@ -67,7 +62,7 @@ def plot_measurement( If `mass_lists` are given rather than a single `mass_list`, `tspan_bg` must also be two timespans - one for each axis. Default is `None` for no background subtraction. - removebackground (bool): Whether otherwise to subtract pre-determined + remove_background (bool): Whether otherwise to subtract pre-determined background signals if available. Defaults to (not logplot) unit (str): the unit for the MS data. Defaults to "A" for Ampere V_str (str): The name of the value to plot on the lower left y-axis. @@ -127,7 +122,7 @@ def plot_measurement( axes=[axes[0], axes[3]] if (mass_lists or mol_lists) else axes[0], tspan=tspan, tspan_bg=tspan_bg, - removebackground=removebackground, + removebackground=remove_background, mass_list=mass_list, mass_lists=mass_lists, mol_list=mol_list, @@ -151,24 +146,19 @@ def plot_vs_potential( mol_lists=None, tspan=None, tspan_bg=None, - removebackground=None, + remove_background=None, unit=None, logplot=False, legend=True, emphasis="top", **kwargs, ): - """ "Make an EC-MS plot vs time and return the axis handles. + """Make an EC-MS plot vs time and return the axis handles. Allocates tasks to ECPlotter.plot_measurement() and MSPlotter.plot_measurement() - TODO: add all functionality in the legendary plot_experiment() in EC_MS.Plotting - - variable subplot sizing (emphasizing EC or MS) - - plotting of calibrated data (mol_list instead of mass_list) - - units! - Args: - measurement (ECMSMeasurement): defaults to the measurement to which the + measurement (ECMSMeasurement): Defaults to the measurement to which the plotter is bound (self.measurement) axes (list of three matplotlib axes): axes[0] plots the MID data, axes[1] the current vs potential. By default three axes are made with @@ -189,7 +179,7 @@ def plot_vs_potential( If `mass_lists` are given rather than a single `mass_list`, `tspan_bg` must also be two timespans - one for each axis. Default is `None` for no background subtraction. - removebackground (bool): Whether otherwise to subtract pre-determined + remove_background (bool): Whether otherwise to subtract pre-determined background signals if available. Defaults to (not logplot) unit (str): the unit for the MS data. Defaults to "A" for Ampere logplot (bool): Whether to plot the MS data on a log scale (default False) @@ -216,7 +206,7 @@ def plot_vs_potential( axes=[axes[0], axes[2]] if (mass_lists or mol_lists) else axes[0], tspan=tspan, tspan_bg=tspan_bg, - removebackground=removebackground, + removebackground=remove_background, mass_list=mass_list, mass_lists=mass_lists, mol_list=mol_list, diff --git a/src/ixdat/plotters/ms_plotter.py b/src/ixdat/plotters/ms_plotter.py index 4036917e..df942563 100644 --- a/src/ixdat/plotters/ms_plotter.py +++ b/src/ixdat/plotters/ms_plotter.py @@ -1,3 +1,5 @@ +"""Plotter for Mass Spectrometry""" + import numpy as np from .base_mpl_plotter import MPLPlotter @@ -39,7 +41,7 @@ def plot_measurement( available masses as mass_list. Args: - measurement (MSMeasurement): defaults to the one that initiated the plotter + measurement (MSMeasurement): Defaults to the one that initiated the plotter ax (matplotlib axis): Defaults to a new axis axes (list of matplotlib axis): Left and right y-axes if mass_lists are given mass_list (list of str): The names of the m/z values, eg. ["M2", ...] to @@ -69,6 +71,9 @@ def plot_measurement( if removebackground is None: removebackground = not logplot + # Figure out, based on the inputs, whether or not to plot calibrated results + # (`quantified`), specifications for the axis to plot on now (`specs_this_axis`) + # and specifications for the next axis to plot on, if any (`specs_next_axis`): quantified, specs_this_axis, specs_next_axis = self._parse_overloaded_inputs( mass_list, mass_lists, @@ -117,7 +122,8 @@ def plot_measurement( label=v_name, **kwargs, ) - ax.set_ylabel(f"signal / [{unit}]") + if quantified: + ax.set_ylabel(f"signal / [{unit}]") ax.set_xlabel("time / [s]") if specs_next_axis: self.plot_measurement( @@ -175,7 +181,7 @@ def plot_vs( Args: x_name (str): Name of the variable to plot on the x-axis - measurement (MSMeasurement): defaults to the one that initiated the plotter + measurement (MSMeasurement): Defaults to the one that initiated the plotter ax (matplotlib axis): Defaults to a new axis axes (list of matplotlib axis): Left and right y-axes if mass_lists are given mass_list (list of str): The names of the m/z values, eg. ["M2", ...] to @@ -389,6 +395,7 @@ def _parse_overloaded_inputs( # ----- These are the standard colors for EC-MS plots! ------- # MIN_SIGNAL = 1e-14 # So that the bottom half of the plot isn't wasted on log(noise) +# TODO: This should probably be customizeable from a settings file. STANDARD_COLORS = { "M2": "b", diff --git a/src/ixdat/plotters/sec_plotter.py b/src/ixdat/plotters/sec_plotter.py index 15c3b364..75521d79 100644 --- a/src/ixdat/plotters/sec_plotter.py +++ b/src/ixdat/plotters/sec_plotter.py @@ -1,3 +1,5 @@ +"""Plotters for spectroelectrochemistry. Makes use of those in spectrum_plotter.py""" + import matplotlib as mpl from .base_mpl_plotter import MPLPlotter @@ -49,8 +51,8 @@ def plot_measurement( plot, axes[1] for potential, and axes[2] for current. The axes are optional and a new set of axes, where axes[1] and axes[2] are twinned on x, are generated if not provided. - V_ref (float): potential to use as reference for calculating optical density - t_ref (float): time to use as a reference for calculating optical density + V_ref (float): Potential to use as reference for calculating optical density + t_ref (float): Time to use as a reference for calculating optical density cmap_name (str): The name of the colormap to use. Defaults to "inferno", which ranges from black through red and orange to yellow-white. "jet" is also good. diff --git a/src/ixdat/plotters/spectrum_plotter.py b/src/ixdat/plotters/spectrum_plotter.py index 63abe240..87d55a8c 100644 --- a/src/ixdat/plotters/spectrum_plotter.py +++ b/src/ixdat/plotters/spectrum_plotter.py @@ -1,3 +1,5 @@ +"""Plotters for spectra and spectrumseries.""" + import numpy as np import matplotlib as mpl from matplotlib import pyplot as plt From f8064555230c4b1da917f2ed33f3c0496222db30 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Sun, 13 Mar 2022 11:43:07 +0000 Subject: [PATCH 115/118] implement review of readers --- src/ixdat/readers/autolab.py | 4 ++-- src/ixdat/readers/biologic.py | 1 - src/ixdat/readers/cinfdata.py | 9 ++++++--- src/ixdat/readers/ec_ms_pkl.py | 8 ++++---- src/ixdat/readers/ivium.py | 10 +++++----- src/ixdat/readers/ixdat_csv.py | 5 +++-- src/ixdat/readers/reading_tools.py | 15 +++++---------- src/ixdat/readers/zilien.py | 4 ++-- 8 files changed, 27 insertions(+), 29 deletions(-) diff --git a/src/ixdat/readers/autolab.py b/src/ixdat/readers/autolab.py index fd4ecd6f..576e200a 100644 --- a/src/ixdat/readers/autolab.py +++ b/src/ixdat/readers/autolab.py @@ -30,10 +30,10 @@ def read( timestring_form=STANDARD_TIMESTAMP_FORM, **kwargs ): - """read the ascii export from Autolab's Nova software + """Read the ASCII export from Autolab's Nova software Args: - path_to_file (Path): The full abs or rel path including the suffix (.txt) + path_to_file (Path): The full absolute or relative path including the suffix name (str): The name to use if not the file name cls (Measurement subclass): The Measurement class to return an object of. Defaults to `ECMeasurement` and should probably be a subclass thereof in diff --git a/src/ixdat/readers/biologic.py b/src/ixdat/readers/biologic.py index 8291392d..d71b206b 100644 --- a/src/ixdat/readers/biologic.py +++ b/src/ixdat/readers/biologic.py @@ -99,7 +99,6 @@ def read(self, path_to_file, name=None, cls=ECMeasurement, **kwargs): Args: path_to_file (Path): The full abs or rel path including the ".mpt" extension - cls (Measurement class): The class of the measurement to return **kwargs (dict): Key-word arguments are passed to ECMeasurement.__init__ name (str): The name to use if not the file name cls (Measurement subclass): The Measurement class to return an object of. diff --git a/src/ixdat/readers/cinfdata.py b/src/ixdat/readers/cinfdata.py index eda62b3a..418b0ac1 100644 --- a/src/ixdat/readers/cinfdata.py +++ b/src/ixdat/readers/cinfdata.py @@ -1,4 +1,4 @@ -"""Module defining the ixdat csv reader, so ixdat can read the files it exports.""" +"""Module defining readers for DTU Surfcat's legendary cinfdata system""" from pathlib import Path import numpy as np @@ -78,7 +78,7 @@ def read(self, path_to_file, name=None, cls=None, **kwargs): measurement as `measurement.reader.attribute_name`. Args: - path_to_file (Path): The full abs or rel path including the ".mpt" extension + path_to_file (Path): The full abs or rel path including the ".txt" extension **kwargs (dict): Key-word arguments are passed to ECMeasurement.__init__ """ path_to_file = Path(path_to_file) if path_to_file else self.path_to_file @@ -203,6 +203,9 @@ def get_column_unit(column_name): unit_name = "A" elif column_name.startswith("M") and column_name.endswith("-x"): unit_name = "s" - else: # TODO: Figure out how cinfdata represents units for other stuff. + else: + # TODO: Figure out how cinfdata represents units for other stuff. + # see https://github.com/ixdat/ixdat/pull/30/files#r811432543, and + # https://github.com/CINF/cinfdata/blob/master/sym-files2/export_data.py#L125 unit_name = None return unit_name diff --git a/src/ixdat/readers/ec_ms_pkl.py b/src/ixdat/readers/ec_ms_pkl.py index 8ff9c7aa..6cf08eb2 100644 --- a/src/ixdat/readers/ec_ms_pkl.py +++ b/src/ixdat/readers/ec_ms_pkl.py @@ -6,7 +6,7 @@ from .biologic import BIOLOGIC_COLUMN_NAMES, get_column_unit -ECMSMeasruement = TECHNIQUE_CLASSES["EC-MS"] +ECMSMeasurement = TECHNIQUE_CLASSES["EC-MS"] class EC_MS_CONVERTER: @@ -21,7 +21,7 @@ def read(self, file_path, cls=None, **kwargs): Args: path_to_file (Path): The full abs or rel path including the - ".pkl" extension. + ".pkl" extension. """ with open(file_path, "rb") as f: ec_ms_dict = pickle.load(f) @@ -39,7 +39,7 @@ def read(self, file_path, cls=None, **kwargs): def measurement_from_ec_ms_dataset( ec_ms_dict, name=None, - cls=ECMSMeasruement, + cls=ECMSMeasurement, reader=None, technique=None, **kwargs, @@ -48,7 +48,7 @@ def measurement_from_ec_ms_dataset( This loops through the keys of the EC-MS dict and searches for MS and EC data. Names the dataseries according to their names in the original - dict. Omitts any other data as well as metadata. + dict. Omits any other data as well as metadata. Args: ec_ms_dict (dict): The EC_MS data dictionary diff --git a/src/ixdat/readers/ivium.py b/src/ixdat/readers/ivium.py index 5a2440a5..97cfc0ff 100644 --- a/src/ixdat/readers/ivium.py +++ b/src/ixdat/readers/ivium.py @@ -17,7 +17,7 @@ class IviumDataReader: """Class for reading single ivium files""" def read(self, path_to_file, cls=None, name=None, cycle_number=0, **kwargs): - """read the ascii export from the Ivium software + """Read the ASCII export from the Ivium software Args: path_to_file (Path): The full abs or rel path including the suffix (.txt) @@ -35,11 +35,11 @@ def read(self, path_to_file, cls=None, name=None, cycle_number=0, **kwargs): name = name or self.path_to_file.name with open(self.path_to_file, "r") as f: - timesting_line = f.readline() # we need this for tstamp + timestring_line = f.readline() # we need this for tstamp columns_line = f.readline() # we need this to get the column names first_data_line = f.readline() # we need this to check the column names tstamp = timestamp_string_to_tstamp( - timesting_line.strip(), + timestring_line.strip(), form="%d/%m/%Y %H:%M:%S", # like '04/03/2021 19:42:30' ) @@ -91,7 +91,7 @@ class IviumDatasetReader: """Class for reading sets of ivium files exported together""" def read(self, path_to_file, cls=None, name=None, **kwargs): - """Return a Measurement containing the data of an ivium dataset, + """Return a measurement containing the data of an ivium dataset, An ivium dataset is a group of ivium files exported together. They share a folder and a base name, and are suffixed "_1", "_2", etc. @@ -106,7 +106,7 @@ def read(self, path_to_file, cls=None, name=None, **kwargs): name (str): The name of the dataset. Defaults to the base name of the dataset kwargs: key-word arguments are included in the dictionary for cls.from_dict() - Returns cls or ECMeasurement: a measurement object with the ivium data + Returns cls or ECMeasurement: A measurement object with the ivium data """ self.path_to_file = Path(path_to_file) diff --git a/src/ixdat/readers/ixdat_csv.py b/src/ixdat/readers/ixdat_csv.py index 2927a4d2..789152eb 100644 --- a/src/ixdat/readers/ixdat_csv.py +++ b/src/ixdat/readers/ixdat_csv.py @@ -60,7 +60,8 @@ def __init__(self): """Initialize a Reader for ixdat-exported .csv files. See class docstring.""" self.name = None self.path_to_file = None - self.n_line = 0 + self.n_line = 0 # TODO: decide if this is part of API. + # as per https://github.com/ixdat/ixdat/pull/30/files#r816204939 self.place_in_file = "header" self.header_lines = [] self.tstamp = None @@ -277,7 +278,7 @@ def read(self, path_to_file, name=None, cls=None, **kwargs): IxdatCSVReader. Then it uses pandas to read the data. Args: - path_to_file (Path): The full abs or rel path including the ".mpt" extension + path_to_file (Path): The full absolute or relative path including extension name (str): The name of the measurement to return (defaults to path_to_file) cls (Spectrum subclass): The class of measurement to return. By default, cls will be determined from the technique specified in the header of diff --git a/src/ixdat/readers/reading_tools.py b/src/ixdat/readers/reading_tools.py index 6ea47454..df0c2ee5 100644 --- a/src/ixdat/readers/reading_tools.py +++ b/src/ixdat/readers/reading_tools.py @@ -28,19 +28,14 @@ def timestamp_string_to_tstamp( the standard timestamp form. """ if form: - forms = (form,) - struct = None + forms = (form, ) for form in forms: try: - struct = time.strptime(timestamp_string, form) - continue + return time.mktime(time.strptime(timestamp_string, form)) except ValueError: continue - try: - tstamp = time.mktime(struct) - except TypeError: - raise ReadError(f"couldn't parse timestamp_string='{timestamp_string}')") - return tstamp + + raise ReadError(f"couldn't parse timestamp_string='{timestamp_string}')") def prompt_for_tstamp(path_to_file, default="creation", form=STANDARD_TIMESTAMP_FORM): @@ -72,7 +67,7 @@ def prompt_for_tstamp(path_to_file, default="creation", form=STANDARD_TIMESTAMP_ timestamp_string = input( f"Please input the timestamp for the measurement at {path_to_file}.\n" f"Please use the format {form}.\n" - f"Enter nothing to use the default default," + "Enter nothing to use the default default," f" '{default}', which is '{default_timestring}'." ) if timestamp_string: diff --git a/src/ixdat/readers/zilien.py b/src/ixdat/readers/zilien.py index 03838b2f..49fadd9b 100644 --- a/src/ixdat/readers/zilien.py +++ b/src/ixdat/readers/zilien.py @@ -71,7 +71,7 @@ def read(self, path_to_tmp_dir, cls=None, **kwargs): """Make a measurement from all the single-value .tsv files in a Zilien tmp dir Args: - path_to_tmp_dir (Path or str): the path to the tmp dir + path_to_tmp_dir (Path or str): The path to the tmp dir cls (Measurement class): Defaults to ECMSMeasurement """ if path_to_tmp_dir: @@ -129,7 +129,7 @@ def __init__(self, path_to_spectrum=None): self.path_to_spectrum = Path(path_to_spectrum) if path_to_spectrum else None def read(self, path_to_spectrum, cls=None, **kwargs): - """Make a measurement from all the single-value .tsv files in a Zilien tmp dir + """Reat a Zilien spectrum. FIXME: This reader was written hastily and could be designed better. Args: From 77040baddde09c701cf34ae731cac704bbd86c82 Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Sun, 13 Mar 2022 12:07:12 +0000 Subject: [PATCH 116/118] implement review of spectra.py --- src/ixdat/spectra.py | 47 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/src/ixdat/spectra.py b/src/ixdat/spectra.py index 9ff5aad2..3e96c1be 100644 --- a/src/ixdat/spectra.py +++ b/src/ixdat/spectra.py @@ -1,3 +1,5 @@ +"""Base classes for spectra and spectrum series""" + import numpy as np from .db import Saveable, PlaceHolderObject from .data_series import DataSeries, TimeSeries, Field @@ -121,12 +123,12 @@ def from_data( Args: x (np array): x data y (np array): y data - tstamp (timestamp): the timestamp of the spectrum. Defaults to None. + tstamp (timestamp): The timestamp of the spectrum. Defaults to None. x_name (str): Name of the x variable. Defaults to 'x' y_name (str): Name of the y variable. Defaults to 'y' x_unit_name (str): Name of the x unit. Defaults to None y_unit_name (str): Name of the y unit. Defaults to None - kwargs: key-word arguments are passed on ultimately to cls.__init__ + kwargs: Key-word arguments are passed on ultimately to cls.__init__ """ xseries = DataSeries(data=x, name=x_name, unit_name=x_unit_name) yseries = DataSeries(data=y, name=y_name, unit_name=y_unit_name) @@ -137,11 +139,11 @@ def from_series(cls, xseries, yseries, tstamp, **kwargs): """Initiate a spectrum from data. Does so via cls.from_field Args: - xseries (DataSeries): a series with the x data - yseries (DataSeries): a series with the y data. The y data should be a + xseries (DataSeries): A series with the x data + yseries (DataSeries): A series with the y data. The y data should be a vector of the same length as the x data. - tstamp (timestamp): the timestamp of the spectrum. Defaults to None. - kwargs: key-word arguments are passed on ultimately to cls.__init__ + tstamp (timestamp): The timestamp of the spectrum. Defaults to None. + kwargs: Key-word arguments are passed on ultimately to cls.__init__ """ field = Field( data=yseries.data, @@ -254,7 +256,37 @@ def __add__(self, other): class SpectrumSeries(Spectrum): + """The SpectrumSeries class. + + A spectrum series is a data structure including a two-dimensional array, each row of + which is a spectrum, and each column of which is one spot in the spectrum as it + changes with some other variable. + + In ixdat, the data of a spectrum series is organized into a Field, where the y-data + is considered to span a space defined by a DataSeries which is the x data, and a + DataSeries (typically a TimeSeries) which enumerates or specifies when or under + which conditions each spectrum was taken. The spectrum series will consider this + its "time" variable even if it is not actually time. + + The SpectrumSeries class makes the data in this field intuitively available. If + spec is a spectrum series, spec.x is the x data with shape (N, ), spec.t is the + time data with shape (M, ), and spec.y is the spectrum data with shape (M, N). + """ + def __init__(self, *args, **kwargs): + """Initiate a spectrum series + + Args: + name (str): The name of the spectrum series + metadata (dict): Free-form spectrum metadata. Must be json-compatible. + technique (str): The spectrum technique + sample_name (str): The sample name + reader (Reader): The reader, if read from file + tstamp (float): The unix epoch timestamp of the spectrum + field (Field): The Field containing the data (x, y, and tstamp) + field_id (id): The id in the data_series table of the Field with the data, + if the field is not yet loaded from backend. + """ if "technique" not in kwargs: kwargs["technique"] = "spectra" super().__init__(*args, **kwargs) @@ -263,7 +295,8 @@ def __init__(self, *args, **kwargs): @property def yseries(self): # Should this return an average or would that be counterintuitive? - raise BuildError(f"{self} has no single y-series. Index it to get a Spectrum.") + raise BuildError(f"{self} has no single y-series. Index it to get a Spectrum " + "or see `y_average`") @property def tseries(self): From 76bd43b2204dc0e68b0d04c761ad9719cc78c81c Mon Sep 17 00:00:00 2001 From: "soren@thomson" Date: Sun, 13 Mar 2022 19:55:18 +0000 Subject: [PATCH 117/118] implement review of techniques --- .../reader_testers/test_cinfdata_reader.py | 11 ++++++++ src/ixdat/measurements.py | 2 ++ src/ixdat/plotters/ecms_plotter.py | 24 ++++++++-------- src/ixdat/techniques/analysis_tools.py | 21 ++++++++------ src/ixdat/techniques/cv.py | 19 +++++++------ src/ixdat/techniques/deconvolution.py | 15 ++++++---- src/ixdat/techniques/ec.py | 28 +++++++++---------- src/ixdat/techniques/ec_ms.py | 16 ++++++++--- src/ixdat/techniques/ms.py | 16 ++++++++++- .../techniques/spectroelectrochemistry.py | 11 ++++---- tasks.py | 4 +-- 11 files changed, 106 insertions(+), 61 deletions(-) diff --git a/development_scripts/reader_testers/test_cinfdata_reader.py b/development_scripts/reader_testers/test_cinfdata_reader.py index 123a28ac..124e34d6 100644 --- a/development_scripts/reader_testers/test_cinfdata_reader.py +++ b/development_scripts/reader_testers/test_cinfdata_reader.py @@ -32,3 +32,14 @@ fig = axes[0].get_figure() fig.tight_layout() # fig.savefig("../../docs/source/figures/ec_ms.svg") + +ecms_meas.set_bg(tspan_bg=[0, 10]) + +cv = ecms_meas.as_cv() +cv.redefine_cycle(start_potential=0.39, redox=False) + +axes = cv[2].plot(mass_list=["M2", "M44"], logplot=False, ) +axes = cv[1].plot( + mass_list=["M2", "M44"], linestyle="--", axes=axes, logplot=False, +) +axes[0].get_figure().savefig("Trimarco2018_ixdat.png") diff --git a/src/ixdat/measurements.py b/src/ixdat/measurements.py index c6c95ac2..d8766bbd 100644 --- a/src/ixdat/measurements.py +++ b/src/ixdat/measurements.py @@ -708,6 +708,8 @@ def grab(self, item, tspan=None, include_endpoints=False, tspan_bg=None): Args: item (str): The name of the DataSeries to grab data for + TODO: Should this be called "name" or "key" instead? And/or, should + the argument to __getitem__ be called "item" instead of "key"? tspan (iter of float): Defines the timespan with its first and last values. Optional. By default the entire time of the measurement is included. include_endpoints (bool): Whether to add a points at t = tspan[0] and diff --git a/src/ixdat/plotters/ecms_plotter.py b/src/ixdat/plotters/ecms_plotter.py index 7cb0dd0a..393e98f6 100644 --- a/src/ixdat/plotters/ecms_plotter.py +++ b/src/ixdat/plotters/ecms_plotter.py @@ -25,10 +25,10 @@ def plot_measurement( tspan_bg=None, remove_background=None, unit=None, - V_str=None, # TODO: Depreciate, replace with v_name, j_name - J_str=None, - V_color="k", - J_color="r", # TODO: Depreciate, replace with v_name, j_name + v_name=None, # TODO: Depreciate, replace with v_name, j_name + j_name=None, + v_color="k", + j_color="r", # TODO: Depreciate, replace with v_name, j_name logplot=None, legend=True, emphasis="top", @@ -65,12 +65,12 @@ def plot_measurement( remove_background (bool): Whether otherwise to subtract pre-determined background signals if available. Defaults to (not logplot) unit (str): the unit for the MS data. Defaults to "A" for Ampere - V_str (str): The name of the value to plot on the lower left y-axis. + v_name (str): The name of the value to plot on the lower left y-axis. Defaults to the name of the series `measurement.potential` - J_str (str): The name of the value to plot on the lower right y-axis. + j_name (str): The name of the value to plot on the lower right y-axis. Defaults to the name of the series `measurement.current` - V_color (str): The color to plot the variable given by 'V_str' - J_color (str): The color to plot the variable given by 'J_str' + v_color (str): The color to plot the variable given by 'V_str' + j_color (str): The color to plot the variable given by 'J_str' logplot (bool): Whether to plot the MS data on a log scale (default True unless mass_lists are given) legend (bool): Whether to use a legend for the MS data (default True) @@ -102,10 +102,10 @@ def plot_measurement( measurement=measurement, axes=[axes[1], axes[2]], tspan=tspan, - v_name=V_str, - j_name=J_str, - v_color=V_color, - j_color=J_color, + v_name=v_name, + j_name=j_name, + v_color=v_color, + j_color=j_color, **kwargs, ) if ( diff --git a/src/ixdat/techniques/analysis_tools.py b/src/ixdat/techniques/analysis_tools.py index 883a2d06..3b937390 100644 --- a/src/ixdat/techniques/analysis_tools.py +++ b/src/ixdat/techniques/analysis_tools.py @@ -1,3 +1,5 @@ +"""Miscellaneous tools for data analysis used in measurement techniques""" + import numpy as np from scipy.optimize import minimize @@ -70,10 +72,11 @@ def before(a, b): def calc_sharp_v_scan(t, v, res_points=10): """Calculate the discontinuous rate of change of v with respect to t - t (np.array): the data of the independent variable, typically time - v (np.array): the data of the dependent variable - res_points (int): the resolution in data points, i.e. the spacing used in - the slope equation v_scan = (v2 - v1) / (t2 - t1) + Args: + t (np.array): the data of the independent variable, typically time + v (np.array): the data of the dependent variable + res_points (int): the resolution in data points, i.e. the spacing used in + the slope equation v_scan = (v2 - v1) / (t2 - t1) """ # the scan rate is dV/dt. This is a numerical calculation of dV/dt: v_behind = np.append(np.tile(v[0], res_points), v[:-res_points]) @@ -123,12 +126,12 @@ def find_signed_sections(x, x_res=0.001, res_points=10): res_points (int): The minimum number of consecutive data points in x that must have the same sign (or be ~0) to constitute a section of the data. """ - pos_mask = x < -x_res - neg_mask = x > x_res - zero_mask = abs(x) < x_res + mask_negative = x < -x_res + mask_positive = x > x_res + mask_zero = abs(x) < x_res - section_types = ["positive", "negative", "zero"] - the_masks = [pos_mask, neg_mask, zero_mask] + section_types = ["negative", "positive", "zero"] + the_masks = [mask_negative, mask_positive, mask_zero] for mask in the_masks: mask[-2] = False diff --git a/src/ixdat/techniques/cv.py b/src/ixdat/techniques/cv.py index a9dd9abe..0fe6ce09 100644 --- a/src/ixdat/techniques/cv.py +++ b/src/ixdat/techniques/cv.py @@ -47,7 +47,7 @@ def __getitem__(self, key): step = 1 key = list(range(start, stop, step)) if isinstance(key, (int, list)): - if type(key) is list and not all([type(i) is int for i in key]): + 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 @@ -122,14 +122,14 @@ def redefine_cycle(self, start_potential=None, redox=None, N_points=5): self.replace_series("cycle", new_cycle_series) def select_sweep(self, vspan, t_i=None): - """Return a CyclicVoltammagram for while the potential is sweeping through vspan + """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. + t_i (float): Optional. Time before which the sweep can't start """ tspan = tspan_passing_through( t=self.t, @@ -142,9 +142,10 @@ def select_sweep(self, vspan, t_i=None): 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. + 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( @@ -200,7 +201,7 @@ 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 + 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] @@ -226,8 +227,8 @@ def diff_with(self, other, v_list=None, cls=None, v_scan_res=0.001, res_points=1 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() + v_scan_res (float): see :meth:`get_timed_sweeps` + res_points (int): see :meth:`get_timed_sweeps` """ vseries = self.potential diff --git a/src/ixdat/techniques/deconvolution.py b/src/ixdat/techniques/deconvolution.py index f359f98b..f06f2842 100644 --- a/src/ixdat/techniques/deconvolution.py +++ b/src/ixdat/techniques/deconvolution.py @@ -9,12 +9,14 @@ from numpy.fft import fft, ifft, ifftshift, fftfreq # noqa import numpy as np +# FIXME: too much abbreviation in this module. + class DecoMeasurement(ECMSMeasurement): """Class implementing deconvolution of EC-MS data""" - def __intit__(self, name, **kwargs): - """initialize a deconvolution EC-MS measurement + def __init__(self, name, **kwargs): + """Initialize a deconvolution EC-MS measurement Args: name (str): The name of the measurement""" @@ -34,6 +36,7 @@ def grab_partial_current( tspan_bg (list): Timespan that corresponds to the background signal. snr (int): signal-to-noise ratio used for Wiener deconvolution. """ + # TODO: comments in this method so someone can tell what's going on! t_sig, v_sig = self.grab_cal_signal(signal_name, tspan=tspan, tspan_bg=tspan_bg) @@ -98,7 +101,7 @@ class Kernel: # TODO: Reference equations to paper. def __init__( self, - parameters={}, + parameters={}, # FIXME: no mutable default arguments! MS_data=None, EC_data=None, ): @@ -120,11 +123,11 @@ def __init__( current, potential). """ - if MS_data is not None and parameters: # TODO: Make two different classes + if MS_data and parameters: # TODO: Make two different classes raise Exception( "Kernel can only be initialized with data OR parameters, not both" ) - if EC_data is not None and MS_data is not None: + if EC_data and MS_data: print("Generating kernel from measured data") self.type = "measured" elif parameters: @@ -210,6 +213,8 @@ def calculate_kernel(self, dt=0.1, duration=100, norm=True, matrix=False): tdiff = t_kernel * diff_const / (work_dist ** 2) def fs(s): + # See Krempl et al, 2021. Equation 6. + # https://pubs.acs.org/doi/abs/10.1021/acs.analchem.1c00110 return 1 / ( sqrt(s) * sinh(sqrt(s)) + (vol_gas * henry_vola / 0.196e-4 / work_dist) diff --git a/src/ixdat/techniques/ec.py b/src/ixdat/techniques/ec.py index 7e633388..3e1b8517 100644 --- a/src/ixdat/techniques/ec.py +++ b/src/ixdat/techniques/ec.py @@ -33,7 +33,7 @@ class ECMeasurement(Measurement): - 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 ms_calibration and/or correction + - 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 if the name of the raw `ValueSeries` (what the acquisition software calls it) changes. Indeed @@ -173,8 +173,8 @@ def calibrations(self): """The list of calibrations of the measurement. The following is necessary to ensure that all EC Calibration parameters are - joined in a single ms_calibration when processing. So that "potential" is both - calibrated to RHE and ohmic drop corrected, even if the two ms_calibration + joined in a single calibration when processing. So that "potential" is both + calibrated to RHE and ohmic drop corrected, even if the two calibration parameters were added separately. """ full_calibration_list = self.calibration_list @@ -188,7 +188,7 @@ def calibrations(self): @property def ec_calibration(self): - """A ms_calibration joining the first RE_vs_RHE, A_el, and R_Ohm""" + """A calibration joining the first RE_vs_RHE, A_el, and R_Ohm""" return ECCalibration(RE_vs_RHE=self.RE_vs_RHE, A_el=self.A_el, R_Ohm=self.R_Ohm) @property @@ -226,9 +226,9 @@ def calibrate( 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] - tstamp (flaot): The timestamp at which the ms_calibration was done (defaults + tstamp (flaot): The timestamp at which the calibration was done (defaults to now) - cal_name (str): The name of the ms_calibration. + cal_name (str): The name of the calibration. """ if not (RE_vs_RHE or A_el or R_Ohm): print("Warning! Ignoring attempt to calibrate without any parameters.") @@ -300,7 +300,7 @@ def as_cv(self): class ECCalibration(Calibration): - """An electrochemical ms_calibration with RE_vs_RHE, A_el, and/or R_Ohm""" + """An electrochemical calibration with RE_vs_RHE, A_el, and/or R_Ohm""" extra_column_attrs = {"ec_calibration": {"RE_vs_RHE", "A_el", "R_Ohm"}} # TODO: https://github.com/ixdat/ixdat/pull/11#discussion_r677552828 @@ -318,16 +318,16 @@ def __init__( """Initiate a Calibration Args: - name (str): The name of the ms_calibration - technique (str): The technique of the ms_calibration - tstamp (float): The time at which the ms_calibration took place or is valid + name (str): The name of the calibration + technique (str): The technique of the calibration + tstamp (float): The time at which the calibration took place or is valid measurement (ECMeasurement): Optional. A measurement to calibrate by default RE_vs_RHE (float): The reference electrode potential on the RHE scale in [V] A_el (float): The electrode area in [cm^2] R_Ohm (float): The ohmic drop resistance in [Ohm] """ - Calibration.__init__( - self, name=name, technique=technique, tstamp=tstamp, measurement=measurement + super().__init__( + name=name, technique=technique, tstamp=tstamp, measurement=measurement ) self.RE_vs_RHE = RE_vs_RHE self.A_el = A_el @@ -345,11 +345,11 @@ def calibrate_series(self, key, measurement=None): Key should be "potential" or "current". Anything else will return None. - - potential: the ms_calibration looks up "raw_potential" in the measurement, + - "potential": the calibration looks up "raw_potential" in the measurement, shifts it to the RHE potential if RE_vs_RHE is available, corrects it for Ohmic drop if R_Ohm is available, and then returns a calibrated potential series with a name indicative of the corrections done. - - current: The ms_calibration looks up "raw_current" in the measurement, + - "current": The calibration looks up "raw_current" in the measurement, normalizes it to the electrode area if A_el is available, and returns a calibrated current series with a name indicative of whether the normalization was done. diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index ec41430c..082efde5 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -39,6 +39,9 @@ def __init__(self, **kwargs): if "component_measurements" in kwargs: ec_kwargs.update(component_measurements=kwargs["component_measurements"]) ms_kwargs.update(component_measurements=kwargs["component_measurements"]) + if "calibration_list" in kwargs: + ec_kwargs.update(calibration_list=kwargs["calibration_list"]) + ms_kwargs.update(calibration_list=kwargs["calibration_list"]) ECMeasurement.__init__(self, **ec_kwargs) MSMeasurement.__init__(self, **ms_kwargs) self._ec_plotter = None @@ -46,7 +49,7 @@ def __init__(self, **kwargs): @property def ec_plotter(self): - """The default plotter for ECMSMeasurement is ECMSPlotter""" + """A plotter for just plotting the ec data""" if not self._ec_plotter: from ..plotters.ec_plotter import ECPlotter @@ -56,7 +59,7 @@ def ec_plotter(self): @property def ms_plotter(self): - """The default plotter for ECMSMeasurement is ECMSPlotter""" + """A plotter for just plotting the ms data""" if not self._ms_plotter: from ..plotters.ms_plotter import MSPlotter @@ -66,7 +69,12 @@ def ms_plotter(self): @classmethod def from_dict(cls, obj_as_dict): - """Unpack the ECMSCalibration when initiating from a dict""" + """Initiate an ECMSMeasurement from a dictionary representation. + + This unpacks the ECMSCalibration from its own nested dictionary + TODO: Figure out a way for that to happen automatically. + """ + if "calibration" in obj_as_dict: if isinstance(obj_as_dict["calibration"], dict): # FIXME: This is a mess @@ -125,7 +133,7 @@ def ecms_calibration_curve( mass (str): Name of the mass at which to calibrate n_el (str): Number of electrons passed per molecule produced (remember the sign! e.g. +4 for O2 by OER and -2 for H2 by HER) - tspan_list (list of tspan): THe timespans of steady electrolysis + tspan_list (list of tspan): The timespans of steady electrolysis tspan_bg (tspan): The time to use as a background ax (Axis): The axis on which to plot the ms_calibration curve result. Defaults to a new axis. diff --git a/src/ixdat/techniques/ms.py b/src/ixdat/techniques/ms.py index 39d69796..4cf25e09 100644 --- a/src/ixdat/techniques/ms.py +++ b/src/ixdat/techniques/ms.py @@ -145,6 +145,9 @@ def grab_flux( Defaults to True. """ return self.grab( + # grab() invokes __getitem__, which invokes the `Calibration`. Specifically, + # `MSCalibration.calibrate_series()` interprets item names starting with + # "n_" as molecule fluxes, and checks itself for a sensitivity factor. f"n_dot_{mol}", tspan=tspan, tspan_bg=tspan_bg, @@ -328,6 +331,15 @@ def __iter__(self): yield from self.ms_cal_results def calibrate_series(self, key, measurement=None): + """Return a calibrated series for `key` if possible. + + If key starts with "n_", it is interpreted as a molecule flux. This method then + searches the calibration for a sensitivity factor for that molecule uses it to + divide the relevant mass signal from the measurement. Example acceptable keys: + "n_H2", "n_dot_H2". + If the key does not start with "n_", or the calibration can't find a relevant + sensitivity factor and mass signal, this method returns None. + """ measurement = measurement or self.measurement if key.startswith("n_"): # it's a flux! mol = key.split("_")[-1] @@ -459,7 +471,9 @@ def calc_n_dot_0( ): """Calculate the total molecular flux through the capillary in [s^-1] - Uses Equation 4.10 of Daniel's Thesis. + Uses Equation 4.10 of Trimarco, 2017. "Real-time detection of sub-monolayer + desorption phenomena during electrochemical reactions: Instrument development + and applications." PhD Thesis, Technical University of Denmark. Args: w_cap (float): capillary width [m], defaults to self.w_cap diff --git a/src/ixdat/techniques/spectroelectrochemistry.py b/src/ixdat/techniques/spectroelectrochemistry.py index 213ebf9c..44245070 100644 --- a/src/ixdat/techniques/spectroelectrochemistry.py +++ b/src/ixdat/techniques/spectroelectrochemistry.py @@ -49,9 +49,9 @@ def set_reference_spectrum( V_ref (float): The potential to use as the reference spectrum. This will only work if the potential is monotonically increasing. """ - if (not spectrum) and t_ref: + if t_ref and not spectrum: spectrum = self.get_spectrum(t=t_ref) - if (not spectrum) and V_ref: + if V_ref and not spectrum: spectrum = self.get_spectrum(V=V_ref) if not spectrum: raise ValueError("must provide a spectrum, t_ref, or V_ref!") @@ -146,7 +146,7 @@ def get_spectrum(self, V=None, t=None, index=None, name=None): y = counts_interpolater(t) name = name or f"{self.spectra.name}_{t}s" else: - raise ValueError(f"Need t or V or index to select a spectrum!") + raise ValueError("Need t or V or index to select a spectrum!") field = Field( data=y, @@ -178,7 +178,8 @@ def get_dOD_spectrum( V_ref (float): The potential at which to get the reference spectrum t_ref (float): The time at which to get the reference spectrum index_ref (int): The index of the reference spectrum - Return Spectrum: The dOD spectrum. The data is (spectrum.x, spectrum.y) + Return: + Spectrum: The dOD spectrum. The data is (spectrum.x, spectrum.y) """ if V_ref or t_ref or index_ref: spectrum_ref = self.get_spectrum(V=V_ref, t=t_ref, index=index_ref) @@ -196,7 +197,7 @@ def get_dOD_spectrum( def track_wavelength(self, wl, width=10, V_ref=None, t_ref=None, index_ref=None): """Return and cache a ValueSeries for the dOD for a specific wavelength. - The cacheing adds wl_str to the SECMeasurement's data series, where + The caching adds wl_str to the SECMeasurement's data series, where wl_str = "w" + int(wl) This is dOD. The raw is also added as wl_str + "_raw". So, to get the raw counts for a specific wavelength, call this function and diff --git a/tasks.py b/tasks.py index 9a214dc7..9ac54fbc 100644 --- a/tasks.py +++ b/tasks.py @@ -43,8 +43,8 @@ def pytest(context): """ print("# pytest") - return context.run("pytest tests").return_code - # TODO: This now only works if we call it from project root. + with context.cd(THIS_DIR): + return context.run("pytest tests").return_code @task(aliases=["QA", "qa", "check"]) From d051c8f30d93b3b53124b0ad62f915f38252a23b Mon Sep 17 00:00:00 2001 From: ScottSoren Date: Mon, 14 Mar 2022 14:12:40 +0000 Subject: [PATCH 118/118] finish review: ECMS axes order and misc details --- .../reader_testers/test_cinfdata_reader.py | 10 +++++----- docs/source/index.rst | 4 ---- docs/source/tutorials.rst | 2 +- src/ixdat/plotters/base_mpl_plotter.py | 10 ++++++---- src/ixdat/plotters/ec_plotter.py | 8 ++++---- src/ixdat/plotters/ecms_plotter.py | 12 ++++++++++-- src/ixdat/plotters/ms_plotter.py | 3 +-- src/ixdat/techniques/cv.py | 9 +++++---- src/ixdat/techniques/ec_ms.py | 14 ++------------ 9 files changed, 34 insertions(+), 38 deletions(-) diff --git a/development_scripts/reader_testers/test_cinfdata_reader.py b/development_scripts/reader_testers/test_cinfdata_reader.py index 124e34d6..ef796fcd 100644 --- a/development_scripts/reader_testers/test_cinfdata_reader.py +++ b/development_scripts/reader_testers/test_cinfdata_reader.py @@ -28,7 +28,7 @@ ) axes[0].set_ylim([-7, 70]) -axes[-1].set_ylim([-1.8e3, 18e3]) +axes[2].set_ylim([-1.8e3, 18e3]) fig = axes[0].get_figure() fig.tight_layout() # fig.savefig("../../docs/source/figures/ec_ms.svg") @@ -38,8 +38,8 @@ cv = ecms_meas.as_cv() cv.redefine_cycle(start_potential=0.39, redox=False) -axes = cv[2].plot(mass_list=["M2", "M44"], logplot=False, ) -axes = cv[1].plot( - mass_list=["M2", "M44"], linestyle="--", axes=axes, logplot=False, +axes_cv = cv[2].plot(mass_list=["M2", "M44"], logplot=False, ) +axes_cv = cv[1].plot( + mass_list=["M2", "M44"], linestyle="--", axes=axes_cv, logplot=False, ) -axes[0].get_figure().savefig("Trimarco2018_ixdat.png") +axes_cv[0].get_figure().savefig("Trimarco2018_ixdat.png") diff --git a/docs/source/index.rst b/docs/source/index.rst index c7e7c65c..86bcc4d7 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -45,10 +45,6 @@ The :ref:`Introduction` has a list of the techniques and file types supported so This documentation, like ``ixdat`` itself, is a work in progress and we appreciate any feedback or requests `here `_. -Note, we are currently compiling from the -`[user_ready] `_ -branch, not the master branch. - .. toctree:: :maxdepth: 1 diff --git a/docs/source/tutorials.rst b/docs/source/tutorials.rst index 8cface99..427ec52b 100644 --- a/docs/source/tutorials.rst +++ b/docs/source/tutorials.rst @@ -6,7 +6,7 @@ Tutorials ``ixdat`` has a growing number of tutorials and examples available. -Ipython notebook tutorials +Jupyter notebook tutorials -------------------------- Jupyter notebooks are available in the ixdat Tutorials repository: https://github.com/ixdat/tutorials/ diff --git a/src/ixdat/plotters/base_mpl_plotter.py b/src/ixdat/plotters/base_mpl_plotter.py index 2e417657..3620bab4 100644 --- a/src/ixdat/plotters/base_mpl_plotter.py +++ b/src/ixdat/plotters/base_mpl_plotter.py @@ -28,7 +28,7 @@ def new_two_panel_axes(self, n_bottom=1, n_top=1, emphasis="top"): emphasis (str or None): "top" for bigger top panel, "bottom" for bigger bottom panel, None for equal-sized panels - Returns list of axes: top left, bottom left(, bottom right)(, top right) + Returns list of axes: top left, bottom left(, top right, bottom right) """ self.new_ax() # necessary to avoid deleting an open figure, I don't know why if emphasis == "top": @@ -52,9 +52,11 @@ def new_two_panel_axes(self, n_bottom=1, n_top=1, emphasis="top"): axis="x", top=True, bottom=False, labeltop=True, labelbottom=False ) - if n_bottom == 2: - axes += [axes[1].twinx()] + if n_bottom == 2 or n_top == 2: + axes += [None, None] if n_top == 2: - axes += [axes[0].twinx()] + axes[2] = axes[0].twinx() + if n_bottom == 2: + axes[3] = axes[1].twinx() return axes diff --git a/src/ixdat/plotters/ec_plotter.py b/src/ixdat/plotters/ec_plotter.py index 0f0b84ca..c3b9ab68 100644 --- a/src/ixdat/plotters/ec_plotter.py +++ b/src/ixdat/plotters/ec_plotter.py @@ -166,13 +166,13 @@ def plot(self, measurement=None, ax=None): # FIXME: This is probably the wrong use of plotter functions. # see https://github.com/ixdat/ixdat/pull/30/files#r810926968 ax = ECPlotter.plot_vs_potential( - self, measurement=measurement.cv_1, axes=ax, color="g" + self, measurement=measurement.cv_compare_1, axes=ax, color="g" ) ax = ECPlotter.plot_vs_potential( - self, measurement=measurement.cv_2, ax=ax, color="k", linestyle="--" + self, measurement=measurement.cv_compare_2, ax=ax, color="k", linestyle="--" ) - t1, v1 = measurement.cv_1.grab("potential") - j1 = measurement.cv_1.grab_for_t("current", t=t1) + t1, v1 = measurement.cv_compare_1.grab("potential") + j1 = measurement.cv_compare_1.grab_for_t("current", t=t1) j_diff = measurement.grab_for_t("current", t=t1) # a mask which is true when cv_1 had bigger current than cv_2: v_scan = measurement.scan_rate.data diff --git a/src/ixdat/plotters/ecms_plotter.py b/src/ixdat/plotters/ecms_plotter.py index 393e98f6..a71ada25 100644 --- a/src/ixdat/plotters/ecms_plotter.py +++ b/src/ixdat/plotters/ecms_plotter.py @@ -77,6 +77,14 @@ def plot_measurement( emphasis (str or None): "top" for bigger top panel, "bottom" for bigger bottom panel, None for equal-sized panels kwargs (dict): Additional kwargs go to all calls of matplotlib's plot() + + Returns: + list of Axes: (top_left, bottom_left, top_right, bottom_right) where: + axes[0] is top_left is MS data; + axes[1] is bottom_left is potential; + axes[2] is top_right is additional MS data if left and right mass_lists + or mol_lists were plotted (otherwise axes[2] is None); and + axes[3] is bottom_right is current. """ measurement = measurement or self.measurement @@ -100,7 +108,7 @@ def plot_measurement( # then we have EC data! self.ec_plotter.plot_measurement( measurement=measurement, - axes=[axes[1], axes[2]], + axes=[axes[1], axes[3]], tspan=tspan, v_name=v_name, j_name=j_name, @@ -119,7 +127,7 @@ def plot_measurement( self.ms_plotter.plot_measurement( measurement=measurement, ax=axes[0], - axes=[axes[0], axes[3]] if (mass_lists or mol_lists) else axes[0], + axes=[axes[0], axes[2]] if (mass_lists or mol_lists) else axes[0], tspan=tspan, tspan_bg=tspan_bg, removebackground=remove_background, diff --git a/src/ixdat/plotters/ms_plotter.py b/src/ixdat/plotters/ms_plotter.py index df942563..bed7bfea 100644 --- a/src/ixdat/plotters/ms_plotter.py +++ b/src/ixdat/plotters/ms_plotter.py @@ -122,8 +122,7 @@ def plot_measurement( label=v_name, **kwargs, ) - if quantified: - ax.set_ylabel(f"signal / [{unit}]") + ax.set_ylabel(f"signal / [{unit}]") ax.set_xlabel("time / [s]") if specs_next_axis: self.plot_measurement( diff --git a/src/ixdat/techniques/cv.py b/src/ixdat/techniques/cv.py index 0fe6ce09..9026b140 100644 --- a/src/ixdat/techniques/cv.py +++ b/src/ixdat/techniques/cv.py @@ -318,16 +318,17 @@ def diff_with(self, other, v_list=None, cls=None, v_scan_res=0.001, res_points=1 cls = cls or CyclicVoltammogramDiff diff = cls.from_dict(diff_as_dict) - diff.cv_1 = self - diff.cv_2 = other + # 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 class CyclicVoltammogramDiff(CyclicVoltammogram): default_plotter = CVDiffPlotter - cv_1 = None - cv_2 = None + cv_compare_1 = None + cv_compare_2 = None def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/src/ixdat/techniques/ec_ms.py b/src/ixdat/techniques/ec_ms.py index 082efde5..990e1c26 100644 --- a/src/ixdat/techniques/ec_ms.py +++ b/src/ixdat/techniques/ec_ms.py @@ -50,22 +50,12 @@ def __init__(self, **kwargs): @property def ec_plotter(self): """A plotter for just plotting the ec data""" - if not self._ec_plotter: - from ..plotters.ec_plotter import ECPlotter - - self._ec_plotter = ECPlotter(measurement=self) - - return self._ec_plotter + return self.plotter.ec_plotter # the ECPlotter of the measurement's ECMSPlotter @property def ms_plotter(self): """A plotter for just plotting the ms data""" - if not self._ms_plotter: - from ..plotters.ms_plotter import MSPlotter - - self._ms_plotter = MSPlotter(measurement=self) - - return self._ms_plotter + return self.plotter.ms_plotter # the MSPlotter of the measurement's ECMSPlotter @classmethod def from_dict(cls, obj_as_dict):