Add `get_experment_data` method to 0MQ API

Add get_experment_data method to 0MQ API, which returns a pandas.DataFrame containing two columns, time_s and current_amps which correspond to the time in seconds since the start of the DStat experiment, and the measured current in amps, respectively.

Merge request reports

Loading

.conda-recipe/bld.bat

0 → 100644
+15 −0
Changes for .conda-recipe/bld.bat: 15 added lines, 0 removed lines.
Original line number Diff line number Diff line
REM Generate `setup.py` from `pavement.py` definition.
"%PYTHON%" -m paver generate_setup

REM **Workaround** `conda build` runs a copy of `setup.py` named
REM `conda-build-script.py` with the recipe directory as the only argument.
REM This causes paver to fail, since the recipe directory is not a valid paver
REM task name.
REM
REM We can work around this by wrapping the original contents of `setup.py` in
REM an `if` block to only execute during package installation.
"%PYTHON%" -c "input_ = open('setup.py', 'r'); data = input_.read(); input_.close(); output_ = open('setup.py', 'w'); output_.write('\n'.join(['import sys', 'import path_helpers as ph', '''if ph.path(sys.argv[0]).name == 'conda-build-script.py':''', '    sys.argv.pop()', 'else:', '\n'.join([('    ' + d) for d in data.splitlines()])])); output_.close(); print open('setup.py', 'r').read()"

REM Install source directory as Python package.
"%PYTHON%" -m pip install --no-cache --find-links http://192.99.4.95/wheels --trusted-host 192.99.4.95 .
if errorlevel 1 exit 1
+52 −0
Changes for .conda-recipe/meta.yaml: 52 added lines, 0 removed lines.
Original line number Diff line number Diff line
# source will be downloaded prior to filling in jinja templates
# Example assumes that this folder has setup.py in it
source:
  git_url: ../

package:
  name: dstat-interface
{% if GIT_DESCRIBE_NUMBER > '0' %}
  version: {{ GIT_DESCRIBE_TAG[1:] }}.post{{ GIT_DESCRIBE_NUMBER }}
{% else %}
  version: {{ GIT_DESCRIBE_TAG[1:] }}
{% endif %}

build:
  entry_points:
    - dstat-interface = dstat_interface.main:main

  # If this is a new build for the same version, increment the build
  # number. If you do not include this key, it defaults to 0.
  number: 0

requirements:
  build:
    - python
    - paver
    - path_helpers
    - matplotlib
    - pandas
    - psutil
    - pycairo-gtk2
    - pyserial
    - pyyaml
    - pyzmq
    - seaborn
    - si-prefix
    - zeo
    - zmq-plugin
    - zodb

  run:
    - matplotlib
    - pandas
    - psutil
    - pycairo-gtk2
    - pyserial
    - pyyaml
    - pyzmq
    - seaborn
    - si-prefix
    - zeo
    - zmq-plugin
    - zodb
+196 −3
Changes for dstat_interface/analysis.py: 196 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -21,10 +21,15 @@
Functions for analyzing data.
"""
import logging
import datetime as dt
import cStringIO as StringIO
import re

import pygtk
import gtk
from numpy import mean, trapz
import numpy as np
import pandas as pd
import arrow

logger = logging.getLogger('dstat.analysis')

@@ -169,7 +174,7 @@ def _summary_stats(data):
                            (scan, max(data[scan][1]))
                            )
        stats['mean'].append(
                            (scan, mean(data[scan][1]))
                            (scan, np.mean(data[scan][1]))
                            )
    return stats

@@ -190,4 +195,192 @@ def _integrateSpectrum(x, y, target, bandwidth):
            k = i
            break

    return [(0, trapz(y=y[j:k], x=x[j:k]))]
 No newline at end of file
    return [(0, np.trapz(y=y[j:k], x=x[j:k]))]


def integrate_fft(df_fft, target_hz, bandwidth):
    '''
    Integrate a bandwidth around a target frequency in DStat FFT results.

    Args
    ----

        df_data (pandas.DataFrame) : DStat FFT results as returned by the
            `dstat_to_fft_frame` function, including the columns `frequency`
            and `amplitude`.
        target_hz (float) : Target frequency.
        bandwidth (float) : Bandwidth (centered at target frequency) to
            integrate within.

    Returns
    -------

        (float) : Integrated amplitude within bandwidth around target
            frequency (definite integral as approximated by trapezoidal
            rule).  See `numpy.trapz` for more information.
    '''
    df_bandpass = df_fft.loc[(df_fft.frequency >= target_hz - .5 * bandwidth) &
                             (df_fft.frequency <= target_hz + .5 * bandwidth)]

    # Definite integral as approximated by trapezoidal rule.
    return np.trapz(x=df_bandpass.frequency, y=df_bandpass.amplitude)


def dstat_to_fft_frame(df_data, sample_frequency_hz=60., settling_period_s=2.):
    '''
    Compute FFT for set of DStat measurements.

    Args
    ----

        df_data (pandas.DataFrame) : DStat experiment results with at least the
            columns `time_s`, and `current_amps`.
        settling_period_s (float) : Signal settling time (in seconds).  No
            measurements before specified time will be considered when
            computing the FFT.

    Returns
    -------

        df_data (pandas.DataFrame) : DStat FFT table with the columns
            `frequency` and `amplitude`.
    '''
    df_data_i = df_data.loc[(df_data.time_s > settling_period_s)].copy()
    df_data_i.reset_index(drop=True, inplace=True)

    avg = np.mean(df_data_i.current_amps.values)

    # Find first and last rising edge in synchronous signal.
    rising_edge_i = np.zeros(df_data_i.shape[0], dtype=bool)
    rising_edge_i[:-1] = ((df_data_i.current_amps <= avg)[:-1].values &
                          (df_data_i.current_amps > avg)[1:].values)
    df_data_i['rising_edge'] = rising_edge_i
    first_edge, last_edge = df_data_i.loc[df_data_i.rising_edge].index[[0, -1]]

    # Set start and end of FFT measurements range to mean.
    df_data_i.current_amps.values[[first_edge, last_edge]] = avg

    # Restrict FFT to measurements between first and last rising edge.
    df_edges_i = df_data_i.loc[first_edge:last_edge - 1, ['time_s',
                                                          'current_amps']]

    # Length of the signal.
    N = len(df_edges_i)

    # Compute frequencies based on sampling frequency.
    frequencies = np.fft.fftfreq(N, d=1. / sample_frequency_hz)[:N / 2]

    # FFT computing and normalization.
    Y = np.fft.fft(df_edges_i.current_amps.values) / N
    Y = abs(Y[:N / 2])

    # Create data frame from frequency and amplitude arrays.
    df_fft_i = pd.DataFrame(np.column_stack([frequencies, Y]),
                            columns=['frequency', 'amplitude'])
    return df_fft_i


def dstat_to_frame(data_path_i):
    '''
    Convert DStat text file results to ``pandas.DataFrame``.

    Parameters
    ----------
    data_path_i : str
        Path to DStat results text file.

    Returns
    -------
    pandas.DataFrame
        DStat measurements in a table with the column ``name`` and
        ``current_amps``, indexed by ``utc_timestamp`` and ``time_s``.

    Notes
    -----

    **TODO** Currently only works for experiments with current as the only
    measurement column (i.e., chronoamperometry, photodiode).
    '''
    with data_path_i.open('r') as input_i:
        diff = (dt.datetime.utcnow() - dt.datetime.now())
        utc_timestamp = arrow.get(input_i.readline().split(' ')[-1]) + diff

    str_data = StringIO.StringIO('\n'.join(l for l in data_path_i.lines()
                                           if not l.startswith('#')))
    df_data = pd.read_csv(str_data, sep='\s+', header=None)
    df_data.rename(columns={0: 'time_s', 1: 'current_amps'}, inplace=True)
    df_data.insert(0, 'name', re.sub(r'-data$', '', data_path_i.namebase))
    df_data.insert(0, 'utc_timestamp', utc_timestamp.datetime +
                   df_data.index.map(lambda v: dt.timedelta(seconds=v)))
    df_data.set_index(['utc_timestamp', 'time_s'], inplace=True)
    return df_data


def reduce_dstat_data(df_dstat, groupby, settling_period_s=2., bandwidth=1.,
                      summary_fields=[]):
    '''
    Reduce measurements for each DStat experiment in `df_dstat` to a single
    row with an aggregate signal value.

    For continuous detection, the aggregate signal column corresponds to the
    mean `current_amps`.

    For synchronous detection experiments (i.e., where `target_hz` is greater
    than 0), the aggregate signal corresponds to the integrated amplitude of
    the `current_amps` FFT within the bandwidth around target frequency.

    Args
    ----

        df_md_dstat (pandas.DataFrame) : DStat measurements table at least
            containing the columns `current_amps`, and `time_s`. For
            synchronous detection experiments the table must also include the
            columns `sample_frequency_hz`, `target_hz`.
        groupby (object, list) : Column(s) that identify distinct DStat
            experiments.  Each row of the output summary table directly
            corresponds to each distinct combination of the `groupby`
            column(s), where the first column(s) in the output table correspond
            to the respective `groupby` column(s).
        settling_period_s (float) : Measurement settling period in seconds.
            Measurements taken before start time will not be included in
            calculations.
        bandwidth (float) : Bandwidth (centered at synchronous detection
            frequency) to integrate within.
        summary_fields (list) : List of columns to extract first value in each
            group to include in each row of the output summary table.

    Returns
    -------

        (pd.DataFrame) : Table containing the `groupby` column(s), the
            `summary_fields` columns, and the column `signal` (i.e., the
            aggregate signal value).
    '''
    rows = []

    for index_i, df_i in df_dstat.groupby(groupby):
        # Get `pandas.Series` containing summary metadata fields (not including signal).
        summary_i = df_i.iloc[0][summary_fields].copy()
        summary_i.name = index_i

        if 'target_hz' in summary_i and summary_i['target_hz'] > 0:
            # Synchronous detection (e.g., shuttered).
            #
            # Use FFT to integrate signal at bandwidth surrounding target synchonization
            # frequency.
            df_fft_i = dstat_to_fft_frame(df_i, sample_frequency_hz=
                                          summary_i['sample_frequency_hz'],
                                          settling_period_s=settling_period_s)
            summary_i['signal'] = integrate_fft(df_fft_i,
                                                summary_i['target_hz'],
                                                bandwidth)
        else:
            # Continuous detection.
            #
            # Take mean measurement value (after settling period).
            summary_i['signal'] = (df_i.loc[df_i.time_s > settling_period_s]
                                   .current_amps.mean())

        rows.append(list(index_i) + summary_i.tolist())

    return pd.DataFrame(rows, columns=groupby + summary_i.keys().tolist())
+126 −14
Changes for dstat_interface/main.py: 126 added lines, 14 removed lines.
Original line number Diff line number Diff line
@@ -17,8 +17,27 @@
#
#     You should have received a copy of the GNU General Public License
#     along with this program.  If not, see <http://www.gnu.org/licenses/>.

""" GUI Interface for Wheeler Lab DStat """
'''GUI Interface for Wheeler Lab DStat

Attributes
----------
EXPERIMENT_TYPES : pandas.Series
    Enum-like type defining experiment type codes::

        Index    Experiment type                     Value

        CA       Chronoamperometry                   0
        LSV      Linear Sweep Voltammetry            1
        CV       Cyclic Voltammetry                  2
        SWV      Square Wave Voltammetry             3
        DPV      Differential Pulse Voltammetry      4
        PD       Photodiode                          5
        POT      Potentiometry                       6

COLUMN_MAPPING : OrderedDict
    Mapping of :data:`EXPERIMENT_TYPES` code values to list of corresponding
    measurement column names.
'''

import sys
import os
@@ -46,7 +65,16 @@ except ImportError:
    sys.exit(1)
from serial import SerialException
import logging
os.chdir(os.path.dirname(os.path.abspath(sys.argv[0])))
# Add package directory to Python path.
#
# This is required for relative imports, which are required for running under a
# `multiprocessing` process.
parent_dir = os.path.abspath(os.path.dirname(__file__))
if parent_dir not in sys.path:
    sys.path.insert(0, parent_dir)
# Change directory into package parent directory to support loading glade UI
# files using relative paths.
os.chdir(parent_dir)

from version import getVersion
import interface.save as save
@@ -54,6 +82,8 @@ from interface.db import DB_Window
import dstat_comm as comm
import interface.exp_window as exp_window
import interface.adc_pot as adc_pot
import numpy as np
import pandas as pd
import plot
import params
import parameter_test
@@ -77,6 +107,77 @@ root_logger.addHandler(log_handler)

logger = logging.getLogger("dstat.main")

EXPERIMENT_TYPES = pd.Series(0, index=['CA', 'LSV', 'CV', 'SWV', 'DPV', 'PD',
                                       'POT'])
EXPERIMENT_TYPES[:] = range(EXPERIMENT_TYPES.size)

EXPERIMENT_TYPE_NAMES = EXPERIMENT_TYPES.copy()
EXPERIMENT_TYPE_NAMES[:] = ['Chronoamperometry',
                            'Linear Sweep Voltammetry',
                            'Cyclic Voltammetry',
                            'Square Wave Voltammetry',
                            'Differential Pulse Voltammetry',
                            'Photodiode',
                            'Potentiometry']

COLUMN_MAPPING = OrderedDict([(EXPERIMENT_TYPES.CA, ['current_amps']),
                              (EXPERIMENT_TYPES.LSV, ['voltage_volts',
                                                      'current_amps']),
                              (EXPERIMENT_TYPES.CV, ['voltage_volts',
                                                     'current_amps']),
                              (EXPERIMENT_TYPES.SWV, ['voltage_volts',
                                                      'current_amps',
                                                      'forward_current_amps',
                                                      'reverse_current_amps']),
                              (EXPERIMENT_TYPES.DPV, ['voltage_volts',
                                                      'current_amps',
                                                      'forward_current_amps',
                                                      'reverse_current_amps']),
                              (EXPERIMENT_TYPES.PD, ['current_amps']),
                              (EXPERIMENT_TYPES.POT, ['voltage_volts'])])


def dstat_data_to_frame(experiment_type, data):
    '''
    Convert DStat experiment data to ``pandas.DataFrame``.

    Parameters
    ==========
    experiment_type : int
        Experiment type code (one of the values in :data:`EXPERIMENT_TYPES`).
    data : list
        List of numpy array pairs (i.e., tuples).  For each array pair, each
        item in the zipped array has two values; the first value is the *time
        in seconds* since the start of the DStat experiment, the second value
        is an array where each row of the array contains measurements
        corresponding to the respective time point in the time array.  The
        columns in the second array depends on the test being run:

         - Linear Sweep Voltammetry/Cyclic Voltammetry: voltage, current
         - Square Wave Voltammetry/Differential Pulse Voltammetry:
             - voltage, current, forward current, reverse current
         - Photodiode: current
         - Potentiometry: voltage

    Returns
    =======
    pandas.DataFrame
        Table containing columns ``time_s``, ``scan_i``, and additional columns
        matching experiment type (see :data:`COLUMN_MAPPING`).
    '''
    columns = ['time_s'] + COLUMN_MAPPING[experiment_type]
    if not data:
        return pd.DataFrame(None, columns=columns)
    else:
        frames = []
        for i, scan_arrays_i in enumerate(data):
            frame_i = pd.DataFrame(np.column_stack(scan_arrays_i),
                                   columns=columns)
            frame_i.insert(1, 'scan_i', i)
            frames.append(frame_i)
        return pd.concat(frames).reset_index(drop=True)


class Main(object):
    """Main program """
    def __init__(self):
@@ -145,7 +246,7 @@ class Main(object):

        self.serial_combobox.set_active(0)

        #initialize experiment selection combobox
        # Initialize experiment selection combobox
        self.expcombobox = self.builder.get_object('expcombobox')
        self.expcombobox.pack_start(self.cell, True)
        self.expcombobox.add_attribute(self.cell, 'text', 2)
@@ -195,6 +296,10 @@ class Main(object):
        self.active_experiment_id = None
        # UUIDs for completed experiments.
        self.completed_experiment_ids = OrderedDict()
        # Data collected during completed experiments.
        self.completed_experiment_data = OrderedDict()
        # Active experiment type code.
        self.active_experiment_type = None

    def on_window1_destroy(self, object, data=None):
        """ Quit when main window closed."""
@@ -433,6 +538,7 @@ class Main(object):
        # Assign current experiment a unique identifier.
        experiment_id = uuid.uuid4()
        self.active_experiment_id = experiment_id
        self.active_experiment_type = self.expcombobox.get_active()
        logger.info("Current measurement id: %s", experiment_id.hex)

        self.metadata = metadata
@@ -496,7 +602,6 @@ class Main(object):
        while comm.serial_instance.data_pipe_p.poll(): # Clear data pipe
            comm.serial_instance.data_pipe_p.recv()

        selection = self.expcombobox.get_active()
        parameters = {}
        parameters['version'] = self.version
        parameters['metadata'] = self.metadata
@@ -519,7 +624,7 @@ class Main(object):
            self.stopbutton.set_sensitive(True)
            self.statusbar.remove_all(self.error_context_id)

            if selection == 0:  # CA
            if self.active_experiment_type == EXPERIMENT_TYPES.CA:
                # Add experiment parameters to existing
                parameters.update(self.exp_window.get_params('cae'))
                if not parameters['potential']:
@@ -538,7 +643,7 @@ class Main(object):

                return experiment_id

            elif selection == 1: # LSV
            elif self.active_experiment_type == EXPERIMENT_TYPES.LSV:
                parameter_test.lsv_test(parameters)

                self.current_exp = comm.LSVExp(parameters)
@@ -546,7 +651,7 @@ class Main(object):

                return experiment_id

            elif selection == 2: # CV
            elif self.active_experiment_type == EXPERIMENT_TYPES.CV:
                parameter_test.cv_test(parameters)

                self.current_exp = comm.CVExp(parameters)
@@ -554,7 +659,7 @@ class Main(object):

                return experiment_id

            elif selection == 3:  # SWV
            elif self.active_experiment_type == EXPERIMENT_TYPES.SWV:
                parameter_test.swv_test(parameters)

                self.current_exp = comm.SWVExp(parameters)
@@ -562,7 +667,7 @@ class Main(object):

                return experiment_id

            elif selection == 4:  # DPV
            elif self.active_experiment_type == EXPERIMENT_TYPES.DPV:
                parameter_test.dpv_test(parameters)

                self.current_exp = comm.DPVExp(parameters)
@@ -570,7 +675,7 @@ class Main(object):

                return experiment_id

            elif selection == 6:  # PD
            elif self.active_experiment_type == EXPERIMENT_TYPES.PD:
                parameter_test.pd_test(parameters)

                self.current_exp = comm.PDExp(parameters)
@@ -578,7 +683,7 @@ class Main(object):

                return experiment_id

            elif selection == 7:  # POT
            elif self.active_experiment_type == EXPERIMENT_TYPES.POT:
                if not (self.version[0] >= 1 and self.version[1] >= 2):
                    self.statusbar.push(self.error_context_id,
                                "v1.1 board does not support potentiometry.")
@@ -838,7 +943,6 @@ class Main(object):
                    )

                self.db_window.params = {'measure_name_entry':newname}
        
        # uDrop
        # UI stuff
        finally:
@@ -849,6 +953,10 @@ class Main(object):
            self.stopbutton.set_sensitive(False)

            self.start_ocp()
            # Save current measurements for experiment in `pandas.DataFrame`.
            self.completed_experiment_data[self.active_experiment_id] =\
                dstat_data_to_frame(self.active_experiment_type,
                                    self.current_exp.data['data'])
            self.completed_experiment_ids[self.active_experiment_id] =\
                datetime.utcnow()

@@ -932,8 +1040,12 @@ class Main(object):
            self.plugin = None


if __name__ == "__main__":
def main():
    multiprocessing.freeze_support()
    gobject.threads_init()
    MAIN = Main()
    gtk.main()


if __name__ == "__main__":
    main()
+66 −2
Changes for dstat_interface/plot.py: 66 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -20,8 +20,11 @@
"""
Creates data plot.
"""
import gtk
from matplotlib.figure import Figure
import gtk
import matplotlib as mpl
import matplotlib.pyplot as plt
import si_prefix as si

#from matplotlib.backends.backend_gtkcairo\
#   import FigureCanvasGTKCairo as FigureCanvas
@@ -36,8 +39,15 @@ try:
except ImportError:
    pass

from numpy import sin, linspace, pi, mean, trapz
from numpy import mean, trapz
from scipy import fft, arange
from analysis import dstat_to_fft_frame


# Format float values as string w.r.t. amps, e.g., `A`, `mA`, `uA`, etc.
A_formatter = mpl.ticker.FuncFormatter(lambda x, pos:
                                       '{}A'.format(si.si_format(x)))


def plotSpectrum(y,Fs):
    """
@@ -144,6 +154,7 @@ class PlotBox(object):
                Experiment.data['data'][line_number][1][1::divisor])
        self.axe1.lines[line_number].set_xdata(
                Experiment.data['data'][line_number][0][1::divisor])
        # Format y-axis tick labels to be like `1.0nA`, `3.7mA`, etc.

    def changetype(self, Experiment):
        """Change plot type. Set axis labels and x bounds to those stored
@@ -200,3 +211,56 @@ class FT_Box(PlotBox):
        self.figure.canvas.draw()


def plot_dstat_data(df_data, settling_period_s=2., axes=None, label=None):
    '''
    Plot DStat experiment current measurement results.

    Args
    ----

        df_data (pandas.DataFrame) : DStat experiment results with at
            least the columns `time_s`, and `current_amps`.  For synchronous
            detection experiments the table must also include the columns
            `sample_frequency_hz`, `target_hz`.
        axes (list) : List of at least two `matplotlib` axes to plot to.  The
            first axis is used to plot the `current_amp` values.  The second
            axis is used to plot the FFT for experiments using synchronous
            detection. If `None`, axes are automatically created.

    Returns
    -------

        (list) : List of two `matplotlib` axes use for current amps and FFT
            plots, respectively.
    '''
    if axes is None:
        fig, axes = plt.subplots(2, figsize=(12, 8))

    # Get style properties to use for plot `i`.
    plot_props = axes[0]._get_lines.prop_cycler.next()
    # Plot measured DStat current at each time point.
    df_data.set_index('time_s').current_amps.plot(ax=axes[0], label=label,
                                                  **plot_props)

    # Compute median measured current.  The median helps to eliminate outliers.
    median = df_data.current_amps.median()
    # Plot median as a straight line.
    axes[0].plot(axes[0].get_xlim(), 2 * [median], linewidth=2, **plot_props)

    #     # Annotate the median with the name of the sample.
    #     axes[0].text(axes[0].get_xlim()[0], median, label, fontsize=14,
    #               color='black')

    # Format y-axes[0] tick labels to be like `1.0nA`, `3.7mA`, etc.
    axes[0].yaxis.set_major_formatter(A_formatter)

    if 'sample_frequency_hz' in df_data and (df_data.iloc[0]
                                             ['sample_frequency_hz'] > 0):
        # Synchronous detection (e.g., shuttered).
        #
        # Compute FFT of signal measured at specified sampling frequency.
        df_fft = dstat_to_fft_frame(df_data, sample_frequency_hz=
                                    df_data.iloc[0]['sample_frequency_hz'],
                                    settling_period_s=settling_period_s)
        df_fft.plot(x='frequency', y='amplitude', ax=axes[1], label=label)
    return axes
Loading
Loading