diff --git a/.conda-recipe/bld.bat b/.conda-recipe/bld.bat new file mode 100644 index 0000000000000000000000000000000000000000..ba2bd5cb6a7e222d7d5b568decf334bdcf6dbecc --- /dev/null +++ b/.conda-recipe/bld.bat @@ -0,0 +1,15 @@ +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 diff --git a/.conda-recipe/meta.yaml b/.conda-recipe/meta.yaml new file mode 100644 index 0000000000000000000000000000000000000000..4bb00c683e856231c4165f023494a3e22316e6ac --- /dev/null +++ b/.conda-recipe/meta.yaml @@ -0,0 +1,52 @@ +# 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 diff --git a/.gitignore b/.gitignore index 868139ac84a3c9d54009d39932c400f70aa6b886..84addf7538124e79fc7d8da841093580afe3195c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ setup.py *.egg-info last_params last_params.yml +.idea diff --git a/dstat_interface/analysis.py b/dstat_interface/analysis.py index 9526872c78e4c7a9845184efb13eec70c546daa5..3a94c81556f1bf289a32ec6cdbc4ee15fdc7eb9e 100755 --- a/dstat_interface/analysis.py +++ b/dstat_interface/analysis.py @@ -1,30 +1,35 @@ #!/usr/bin/env python # DStat Interface - An interface for the open hardware DStat potentiostat -# Copyright (C) 2014 Michael D. M. Dryden - +# Copyright (C) 2014 Michael D. M. Dryden - # Wheeler Microfluidics Laboratory -# -# +# +# # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. -# +# # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. -# +# # You should have received a copy of the GNU General Public License # along with this program. If not, see . """ 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') @@ -34,61 +39,61 @@ class AnalysisOptions(object): self.builder = builder self.builder.add_from_file('interface/analysis_options.glade') self.builder.connect_signals(self) - + self.window = self.builder.get_object('analysis_dialog') self.stats_button = self.builder.get_object('stats_button') self.stats_start = self.builder.get_object('stats_start_spin') self.stats_start_button = self.builder.get_object('stats_start_button') - self.stats_stop = self.builder.get_object('stats_stop_spin') + self.stats_stop = self.builder.get_object('stats_stop_spin') self.stats_stop_button = self.builder.get_object('stats_stop_button') self.stats_button.connect('toggled', self.on_button_toggled_hide, - [self.stats_stop, + [self.stats_stop, self.stats_stop_button, self.stats_start, self.stats_start_button ] ) - + self._params = {'stats_true':False, 'stats_start_true':False, 'stats_stop':0, 'stats_stop_true':False, 'stats_start':0 } - + def show(self): """Show options window.""" self.window.run() self.window.hide() - + def on_button_toggled_hide(self, control, widgets): """Hide unchecked fields""" active = control.get_active() - + for widget in widgets: widget.set_sensitive(active) - + @property def params(self): """Getter for analysis params""" self._params['stats_true'] = self.stats_button.get_active() - + self._params['stats_start_true'] = self.stats_start_button.get_active() self._params['stats_start'] = self.stats_start.get_value() - + self._params['stats_stop_true'] = self.stats_stop_button.get_active() self._params['stats_stop'] = self.stats_stop.get_value() return self._params - + @params.setter def params(self, params): for key in self._params: if key in params: self._params[key] = params[key] - + self.stats_button.set_active(self._params['stats_true']) self.stats_start_button.set_active(self._params['stats_start_true']) self.stats_start.set_value(self._params['stats_start']) @@ -97,32 +102,32 @@ class AnalysisOptions(object): def do_analysis(experiment): """Takes an experiment class instance and runs selected analysis.""" - + experiment.analysis = {} - + if experiment.parameters['stats_true']: if (experiment.parameters['stats_start_true'] or experiment.parameters['stats_stop_true']): - + if experiment.parameters['stats_start_true']: start = experiment.parameters['stats_start'] else: start = min(experiment.data['data'][0][0]) - + if experiment.parameters['stats_stop_true']: stop = experiment.parameters['stats_stop'] else: stop = min(experiment.data['data'][0][0]) - + data = _data_slice(experiment.data['data'], start, stop ) else: data = experiment.data['data'] - + experiment.analysis.update(_summary_stats(data)) - + try: x, y = experiment.data['ft'][0] experiment.analysis['FT Integral'] = _integrateSpectrum( @@ -140,27 +145,27 @@ def _data_slice(data, start, stop): between start and stop (in whatever x-axis units for the experiment type). """ output = [] - + for scan in range(len(data)): t = [] for i in range(len(data[scan])): t.append([]) output.append(tuple(t)) - + for i in range(len(data[scan][0])): # x-axis column if data[scan][0][i] >= start or data[scan][0][i] <= stop: for d in range(len(output[scan])): - output[scan][d].append(data[scan][d][i]) - + output[scan][d].append(data[scan][d][i]) + return output - + def _summary_stats(data): - """Takes data and returns summary statistics of first y variable as dict of + """Takes data and returns summary statistics of first y variable as dict of name, (scan, values). """ - + stats = {'min':[],'max':[], 'mean':[]} - + for scan in range(len(data)): stats['min'].append( (scan, min(data[scan][1])) @@ -169,10 +174,10 @@ 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 - + def _integrateSpectrum(x, y, target, bandwidth): """ Returns integral of range of bandwidth centered on target. @@ -184,10 +189,198 @@ def _integrateSpectrum(x, y, target, bandwidth): if x[i] >= target-bandwidth/2: j = i break - + for i in range(j,len(x)): if x[i] >= target+bandwidth/2: 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()) diff --git a/dstat_interface/main.py b/dstat_interface/main.py index a7e05d1a69100c2f3d1a74c0300622c8993c1802..62ceaf1b5f93ad8e538ceda26f88dee0019636ce 100755 --- a/dstat_interface/main.py +++ b/dstat_interface/main.py @@ -17,8 +17,27 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . +'''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): @@ -105,7 +206,7 @@ class Main(object): self.exp_window = exp_window.Experiments(self.builder) self.analysis_opt_window = analysis.AnalysisOptions(self.builder) - + self.db_window = DB_Window() self.builder.get_object('menu_database_options').connect_object( 'activate', DB_Window.show, self.db_window @@ -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) @@ -179,9 +280,9 @@ class Main(object): 'menu_dropbot_disconnect') self.dropbot_enabled = False self.dropbot_triggered = False - + self.metadata = None # Should only be added to by plugin interface - + self.plot_notebook.get_nth_page( self.plot_notebook.page_num(self.ft_window)).hide() self.plot_notebook.get_nth_page( @@ -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,17 +538,18 @@ 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 - + if self.metadata is not None: logger.info("Loading external metadata") self.db_window.update_from_metadata(self.metadata) elif self.db_window.params['exp_id_entry'] is None: logger.info("DB exp_id field blank, autogenerating") self.db_window.on_exp_id_autogen_button_clicked() - + self.db_window.params = {'measure_id_entry':experiment_id.hex} def exceptions(): @@ -470,13 +576,13 @@ class Main(object): else: nb.get_nth_page(nb.page_num(self.ft_window)).hide() # nb.get_nth_page(nb.page_num(self.period_window)).hide() - + if parameters['db_enable_checkbutton']: if db.current_db is None: db.start_db() elif not db.current_db.connected: db.restart_db() - + comm.serial_instance.proc_pipe_p.send(self.current_exp) # Flush data pipe @@ -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 @@ -507,9 +612,9 @@ class Main(object): try: if param_override is not None: params.set_params(self, param_override) - + parameters.update(params.get_params(self)) - + self.line = 0 self.lastline = 0 self.lastdataline = 0 @@ -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.") @@ -814,7 +919,7 @@ class Main(object): # Database output if self.current_exp.parameters['db_enable_checkbutton']: meta = {} - + if self.current_exp.parameters['metadata'] is not None: metadata = self.current_exp.parameters['metadata'] exp_metakeys = ['experiment_uuid', 'patient_id', 'name'] @@ -824,9 +929,9 @@ class Main(object): if k not in exp_metakeys } ) - + name = self.current_exp.parameters['measure_name_entry'] - + newname = db.current_db.add_results( measurement_uuid=self.active_experiment_id.hex, measurement_name=name, @@ -836,19 +941,22 @@ class Main(object): timestamp=None, data=self.current_exp.export() ) - + self.db_window.params = {'measure_name_entry':newname} - # uDrop # UI stuff finally: self.metadata = None # Reset metadata - + self.spinner.stop() self.startbutton.set_sensitive(True) 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() diff --git a/dstat_interface/plot.py b/dstat_interface/plot.py index 7c5c017f371e2ea7a00a1a02b2a347bb02d6c9fd..ea66ae076b5445c6657b45499dbbd69a40d9422f 100755 --- a/dstat_interface/plot.py +++ b/dstat_interface/plot.py @@ -1,27 +1,30 @@ #!/usr/bin/env python # DStat Interface - An interface for the open hardware DStat potentiostat -# Copyright (C) 2014 Michael D. M. Dryden - +# Copyright (C) 2014 Michael D. M. Dryden - # Wheeler Microfluidics Laboratory -# -# +# +# # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. -# +# # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. -# +# # You should have received a copy of the GNU General Public License # along with this program. If not, see . """ 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 @@ -35,14 +38,21 @@ try: import seaborn as sns 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): """ Plots a Single-Sided Amplitude Spectrum of y(t) - """ + """ y = y-mean(y) n = len(y) # length of the signal k = arange(n) @@ -51,7 +61,7 @@ def plotSpectrum(y,Fs): frq = frq[range(n/2)] # one side frequency range Y = fft(y)/n # fft computing and normalization Y = abs(Y[range(n/2)]) - + return (frq, Y) def integrateSpectrum(x, y, target, bandwidth): @@ -60,52 +70,52 @@ def integrateSpectrum(x, y, target, bandwidth): """ j = 0 k = len(x) - + for i in range(len(x)): if x[i] >= target-bandwidth/2: j = i break - + for i in range(j,len(x)): if x[i] >= target+bandwidth/2: k = i break - + return trapz(y=y[j:k], x=x[j:k]) - + def findBounds(y): start_index = 0; stop_index = len(y)-1; - + for i in range(len(y)): if (y[i] <= mean(y) and y[i+1] > mean(y)): start_index = i break - + for i in range(len(y)): if (y[-(i+1)] <= mean(y) and y[-i] > mean(y)): stop_index = len(y)-1-i # len(y) is last index + 1 break - + return (start_index, stop_index) - - + + class PlotBox(object): """Contains main data plot and associated methods.""" def __init__(self, plotwindow_instance): """Creates plot and moves it to a gtk container. - + Arguments: plotwindow_instance -- gtk container to hold plot. """ - + self.figure = Figure() self.figure.subplots_adjust(left=0.07, bottom=0.07, right=0.96, top=0.96) self.axe1 = self.figure.add_subplot(111) - + self.axe1.plot([0, 1], [0, 1]) - + self.axe1.ticklabel_format(style='sci', scilimits=(0, 3), useOffset=False, axis='y') @@ -117,22 +127,22 @@ class PlotBox(object): self.toolbar = NavigationToolbar(self.canvas, self.win) self.vbox.pack_start(self.toolbar, False, False) self.vbox.reparent(plotwindow_instance) - + def clearall(self): """Remove all lines on plot. """ for i in range(len(self.axe1.lines)): self.axe1.lines.pop(0) self.addline() - + def clearline(self, line_number): """Remove a line specified by line_number.""" self.lines[line_number].remove() self.lines.pop(line_number) - + def addline(self): """Add a new line to plot. (initialized with dummy data)))""" self.axe1.plot([0, 1], [0, 1]) - + def updateline(self, Experiment, line_number): """Update a line specified by line_number with data stored in the Experiment instance. @@ -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 @@ -152,7 +163,7 @@ class PlotBox(object): self.axe1.set_xlabel(Experiment.xlabel) self.axe1.set_ylabel(Experiment.ylabel) self.axe1.set_xlim(Experiment.xmin, Experiment.xmax) - + Experiment.plots['data'] = self self.figure.canvas.draw() @@ -171,7 +182,7 @@ class FT_Box(PlotBox): for i in range(len(data)): if data[i] > target: return i - + y = Experiment.data['data'][line_number][1] x = Experiment.data['data'][line_number][0] freq = Experiment.parameters['adc_rate_hz'] @@ -186,7 +197,7 @@ class FT_Box(PlotBox): self.axe1.lines[line_number].set_ydata(Y) self.axe1.lines[line_number].set_xdata(f) Experiment.data['ft'] = [(f, Y)] - + def changetype(self, Experiment): """Change plot type. Set axis labels and x bounds to those stored in the Experiment instance. Stores class instance in Experiment. @@ -194,9 +205,62 @@ class FT_Box(PlotBox): self.axe1.set_xlabel("Freq (Hz)") self.axe1.set_ylabel("|Y| (A/Hz)") self.axe1.set_xlim(0, Experiment.parameters['adc_rate_hz']/2) - + Experiment.plots['ft'] = self 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 diff --git a/dstat_interface/plugin.py b/dstat_interface/plugin.py index 59ec93d013d7c1a6e3410f6722fc4b6ed5f55edb..e073a1d59a47b8871cf05f78fc4f99d780bd0849 100644 --- a/dstat_interface/plugin.py +++ b/dstat_interface/plugin.py @@ -119,15 +119,26 @@ class DstatPlugin(ZmqPlugin): data = decode_content_data(request) self.parent.metadata = request - def on_execute__save_text(self, request): + def on_execute__get_experiment_data(self, request): ''' Args ---- - save_data_path (str) : Path to file to save text data. + experiment_id (str) : Path to file to save text data. + + Returns + ------- + + (pandas.DataFrame) : Experiment results with columns `time_s` and + `current_amps`. ''' data = decode_content_data(request) - save_text(self.parent.current_exp, data['save_data_path']) + if data['experiment_id'] in self.parent.completed_experiment_ids: + return self.parent.completed_experiment_data[data['experiment_id']] + elif data['experiment_id'] == self.parent.active_experiment_id: + return None + else: + raise KeyError('Unknown experiment ID: %s' % data['experiment_id']) def on_execute__save_plot(self, request): ''' diff --git a/pavement.py b/pavement.py index bf1f907e0cfac6ff06845920459077722865df40..34a65505c633b0de87d4503fb36523d2dae0139c 100644 --- a/pavement.py +++ b/pavement.py @@ -16,10 +16,10 @@ setup(name='dstat_interface', author_email='mdryden@chemutoronto.ca', url='http://microfluidics.utoronto.ca/dstat', license='GPLv3', - packages=['dstat_interface', ], - install_requires=['matplotlib', 'numpy', 'pyserial', 'pyzmq', - 'pyyaml','seaborn', 'zmq-plugin>=0.2.post2', 'zodb', - 'zeo', 'psutil'], + packages=['dstat_interface'], + install_requires=['matplotlib', 'numpy', 'pandas', 'psutil', 'pyserial', + 'pyyaml', 'pyzmq', 'seaborn', 'si-prefix', 'zeo', + 'zmq-plugin>=0.2.post2', 'zodb'], # Install data listed in `MANIFEST.in` include_package_data=True)