# coding: utf-8
"""
Copyright 2011-2016 Ryan Fobel and Christian Fobel

This file is part of dmf_control_board.

dmf_control_board 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.

dmf_control_board 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 dmf_control_board.  If not, see <http://www.gnu.org/licenses/>.
"""
import os
from collections import OrderedDict
import decorator
import time
import copy
import logging
import math
import re
import warnings

import numpy as np
import pandas as pd
from scipy.signal import savgol_filter
import matplotlib.mlab as mlab
from path_helpers import path
from microdrop_utility import Version, FutureVersionError
from base_node import BaseNode

from dmf_control_board_base import DMFControlBoard as Base
from dmf_control_board_base import uint8_tVector
# Import firmware constants
from dmf_control_board_base import INPUT, OUTPUT, HIGH, LOW
from serial_device import SerialDevice
from arduino_helpers.context import auto_context, Board, Uploader
from calibrate.feedback import compute_from_transfer_function

logger = logging.getLogger()


# # Firmware return codes #
RETURN_ATTRS = [attr for attr in dir(Base)
                if attr.startswith('RETURN_')]
RETURN_CODES_BY_NAME = pd.Series([getattr(Base, attr)
                                  for attr in RETURN_ATTRS],
                                 index=[attr[len('RETURN_'):]
                                        for attr in RETURN_ATTRS])
RETURN_CODES_BY_NAME.sort_values(inplace=True)
NAMES_BY_RETURN_CODE = pd.Series(RETURN_CODES_BY_NAME.index,
                                 index=RETURN_CODES_BY_NAME)

# # Firmware command codes #
COMMAND_ATTRS = [attr for attr in dir(Base)
                 if attr.startswith('CMD_')]
COMMAND_CODES_BY_NAME = pd.Series([getattr(Base, attr)
                                   for attr in COMMAND_ATTRS],
                                  index=[attr[len('CMD_'):]
                                         for attr in COMMAND_ATTRS])
COMMAND_CODES_BY_NAME.sort_values(inplace=True)
NAMES_BY_COMMAND_CODE = pd.Series(COMMAND_CODES_BY_NAME.index,
                                  index=COMMAND_CODES_BY_NAME)

# Regex to match control board firmware exception message.  Useful to extract
# command code and return code.
CRE_REMOTE_ERROR = re.compile(r'Error sending command\s+'
                              r'0x(?P<command_hex>[0-9a-fA-F]+)\s+'
                              r'\((?P<command_int>\d+)\).\s+'
                              r'Return code=(?P<return_code_int>\d+).')

CRE_REMOTE_COMMAND_ERROR = re.compile(r'[Cc]ommand\s+'
                                      r'0x(?P<command_hex>[0-9a-fA-F]+)\s+'
                                      r'\((?P<command_int>\d+)\)')


class FirmwareError(Exception):
    '''
    Used to indicate an exception encountered while running a remote command on
    an embedded DMF control board.
    '''
    def __init__(self, command_code, return_code):
        self.command_code = command_code
        self.return_code = return_code

    def __str__(self):
        return (r'%s [command=%s]' %
                (NAMES_BY_RETURN_CODE.get(self.return_code,
                                          self.return_code),
                 NAMES_BY_COMMAND_CODE.get(self.command_code,
                                           self.command_code)))


def feedback_results_to_measurements_frame(feedback_result):
    '''
    Extract measured data from `FeedbackResults` instance into
    `pandas.DataFrame`.
    '''
    index = pd.Index(feedback_result.time * 1e-3, name='seconds')
    df_feedback = pd.DataFrame(np.column_stack([feedback_result.V_fb,
                                                feedback_result.V_hv,
                                                feedback_result.fb_resistor,
                                                feedback_result.hv_resistor]),
                               columns=['V_fb', 'V_hv', 'fb_resistor',
                                        'hv_resistor'],
                               index=index)
    df_feedback.insert(0, 'frequency', feedback_result.frequency)
    return df_feedback


def feedback_results_to_impedance_frame(feedback_result):
    '''
    Extract computed impedance data from `FeedbackResults` instance into
    `pandas.DataFrame`.
    '''
    index = pd.Index(feedback_result.time * 1e-3, name='seconds')
    df_feedback = pd.DataFrame(np.column_stack([feedback_result.V_actuation()
                                                .filled(np.NaN),
                                                feedback_result.capacitance()
                                                .filled(np.NaN),
                                                feedback_result.Z_device()
                                                .filled(np.NaN)]),
                               columns=['V_actuation', 'capacitance',
                                        'impedance'],
                               index=index)
    df_feedback.insert(0, 'frequency', feedback_result.frequency)
    return df_feedback


def package_path():
    return path(os.path.abspath(os.path.dirname(__file__)))


def get_sketch_directory():
    '''
    Return directory containing the `dmf_control_board` Arduino sketch.
    '''
    return os.path.join(package_path(), 'src', 'dmf_control_board')


def get_includes():
    '''
    Return directories containing the `dmf_control_board` Arduino header files.

    Modules that need to compile against `dmf_control_boad` should use this
    function to locate the appropriate include directories.

    Notes
    =====

    For example:

        import dmf_control_board
        ...
        print ' '.join(['-I%s' % i for i in dmf_control_board.get_includes()])
        ...

    '''
    return [get_sketch_directory()]


def get_firmwares():
    '''
    Return `dmf_control_board` compiled Arduino hex file paths.

    This function may be used to locate firmware binaries that are available
    for flashing to [Arduino Mega2560][1] boards.

    [1]: http://arduino.cc/en/Main/arduinoBoardMega2560
    '''
    return OrderedDict([(board_dir.name, [f.abspath() for f in
                                          board_dir.walkfiles('*.hex')])
                        for board_dir in
                        package_path().joinpath('firmware').dirs()])


def get_sources():
    '''
    Return `dmf_control_board` Arduino source file paths.

    Modules that need to compile against `dmf_control_board` should use this
    function to locate the appropriate source files to compile.

    Notes
    =====

    For example:

        import dmf_control_board
        ...
        print ' '.join(dmf_control_board.get_sources())
        ...

    '''
    return get_sketch_directory().files('*.c*')


def safe_getattr(obj, attr, except_types):
    '''
    Execute `getattr` to retrieve the specified attribute from the provided
    object, returning a default value of `None` in the case where the attribute
    does not exist.

    In the case where an exception occurs during the `getattr` call, if the
    exception type is in `except_types`, ignore the exception and return
    `None`.
    '''
    try:
        return getattr(obj, attr, None)
    except except_types:
        return None


class PersistentSettingDoesNotExist(Exception):
    pass


class BadVGND(Exception):
    pass


class FeedbackResults():
    """
    This class stores the impedance results for a single step in the protocol.
    """
    class_version = str(Version(0, 6))

    def __init__(self, voltage, frequency, dt_ms, V_hv, hv_resistor, V_fb,
                 fb_resistor, calibration, area=0, amplifier_gain=None,
                 vgnd_hv=None, vgnd_fb=None):
        self.voltage = voltage
        self.frequency = frequency
        self.V_hv = V_hv
        self.hv_resistor = hv_resistor
        self.V_fb = V_fb
        self.fb_resistor = fb_resistor
        self.time = np.arange(0, len(V_hv)) * dt_ms
        self.calibration = calibration
        self.version = self.class_version
        self.area = area
        self.amplifier_gain = amplifier_gain
        self.vgnd_hv = vgnd_hv
        self.vgnd_fb = vgnd_fb
        self._sanitize_data()

    def _sanitize_data(self):
        self.V_hv[self.hv_resistor < 0] = np.nan
        self.V_fb[self.fb_resistor < 0] = np.nan

    def _upgrade(self):
        """
        Upgrade the serialized object if necessary.

        Raises:
            FutureVersionError: file was written by a future version of the
                software.
        """
        logging.debug('[FeedbackResults]._upgrade()')
        if hasattr(self, 'version'):
            version = Version.fromstring(self.version)
        else:
            version = Version(0)
        logging.debug('[FeedbackResults] version=%s, class_version=%s' %
                      (str(version), self.class_version))
        if version > Version.fromstring(self.class_version):
            logging.debug('[FeedbackResults] version>class_version')
            raise FutureVersionError(Version.fromstring(self.class_version),
                                     version)
        elif version < Version.fromstring(self.class_version):
            if version < Version(0, 1):
                self.calibration = FeedbackCalibration()
            if version < Version(0, 2):
                # flag invalid data points
                self.version = str(Version(0, 2))
                self.fb_resistor[self.V_fb > 5] = -1
                self.hv_resistor[self.V_hv > 5] = -1
            if version < Version(0, 3):
                self.attempt = 0
            if version < Version(0, 4):
                del self.sampling_time_ms
                del self.delay_between_samples_ms
                self.voltage = self.options.voltage
                del self.options
                del self.attempt
            if version < Version(0, 5):
                self.area = 0
                self.version = str(Version(0, 5))
            if version < Version(0, 6):
                self.amplifier_gain = None
                self.vgnd_hv = None
                self.vgnd_fb = None
                self.version = str(Version(0, 6))
                logging.info('[FeedbackResults] upgrade to version %s' %
                             self.version)
        else:
            # Else the versions are equal and don't need to be upgraded.
            pass

    def __setstate__(self, state):
        # convert lists to numpy arrays
        self.__dict__ = state
        for k, v in self.__dict__.items():
            if isinstance(v, list):
                self.__dict__[k] = np.array(v)
        self._upgrade()
        self._sanitize_data()

    def __getstate__(self):
        # convert numpy arrays/floats to standard lists/floats
        out = copy.deepcopy(self.__dict__)
        for k, v in out.items():
            if isinstance(v, np.ndarray):
                out[k] = v.tolist()
        return out

    def V_total(self):
        '''
        Compute the input voltage _(i.e., `V1`)_ based on the measured
        high-voltage feedback values for `V2`, using the high-voltage transfer
        function.
        '''
        ind = mlab.find(self.hv_resistor >= 0)
        V1 = np.empty(self.hv_resistor.shape)
        V1.fill(np.nan)
        V1[ind] = compute_from_transfer_function(self.calibration.hw_version
                                                 .major, 'V1',
                                                 V2=self.V_hv[ind], R1=10e6,
                                                 R2=self.calibration.R_hv
                                                 [self.hv_resistor[ind]],
                                                 C2=self.calibration.C_hv
                                                 [self.hv_resistor[ind]],
                                                 f=self.frequency)
        # convert to masked array
        return np.ma.masked_invalid(pd.Series(V1, pd.to_datetime(self.time, unit='s')
        ).interpolate(method='time').values)

    def V_actuation(self):
        '''
        Return the voltage drop across the device _(i.e., the `Z1` load)_ for
        each feedback measurement.

        Consider the feedback circuit diagrams below for the feedback
        measurement circuits of the two the control board hardware versions.

                         # Hardware V1 #          # Hardware V2 #

                         V_1 @ frequency          V_1 @ frequency
                        ┬    ┯                        ┯
                        │  ┌─┴─┐                    ┌─┴─┐    ┌───┐
            V_actuation │  │Z_1│                    │Z_1│  ┌─┤Z_2├─┐
                        │  └─┬─┘                    └─┬─┘  │ └───┘ │
                        ┴    ├───O V_2                │    │  │\   ├───O V_2
                           ┌─┴─┐                      └────┴──│-\__│
                           │Z_2│                           ┌──│+/
                           └─┬─┘                           │  │/
                            ═╧═                            │
                             ¯                            ═╧═
                                                           ¯

        Note that in the case of hardware version 1, the input voltage `V1` is
        divided across `Z1` *and* the feedback measurement load `Z2`.
        Therefore, the effective _actuation_ voltage across the DMF device is
        less than `V1`.  Specifically, the effective _actuation_ voltage is
        `V1 - V2`.

        In hardware version 2, since the positive terminal of the op-amp is
        attached to _(virtual)_ ground, the negative op-amp terminal is also at
        ground potential.  It follows that the actuation voltage is equal to
        `V1` on hardware version 2.
        '''
        if self.calibration.hw_version.major == 1:
            return self.V_total() - np.array(self.V_fb)
        else:
            return self.V_total()

    def Z_device(self, filter_order=None, window_size=None, tol=0.05):
        '''
        Compute the impedance _(including resistive and capacitive load)_ of
        the DMF device _(i.e., dielectric and droplet)_.

        See `dmf_control_board.calibrate.compute_from_transfer_function`
        docstring for details.
        '''
        ind = mlab.find(self.fb_resistor >= 0)
        Z1 = np.empty(self.fb_resistor.shape)
        Z1.fill(np.nan)

        R2 = self.calibration.R_fb[self.fb_resistor[ind]]
        C2 = self.calibration.C_fb[self.fb_resistor[ind]]
        Z1[ind] = compute_from_transfer_function(self.calibration.hw_version
                                                 .major, 'Z1',
                                                 V1=self.V_total()[ind],
                                                 V2=self.V_fb[ind], R2=R2,
                                                 C2=C2, f=self.frequency)
        # convert to masked array
        Z1 = (np.ma.masked_invalid(pd.TimeSeries(Z1, pd.to_datetime(self.time,
                                                                    unit='s'))
                                   .interpolate(method='time').values))

        # if we're filtering and we don't have a window size specified,
        # automatically determine one
        if filter_order and window_size is None:
            window_size = self._get_window_size(tol)

        # if the filter_order or window size is None or if the window size is
        # smaller than filter_order + 2, don't filter
        if filter_order is None or window_size is None or \
            (window_size and window_size < filter_order + 2):
            pass
        else:
            # if the window size is less than half the sample length
            if window_size and window_size < len(Z1) / 2:
                # suppress polyfit warnings
                with warnings.catch_warnings():
                    warnings.simplefilter("ignore")
                    Z1 = savgol_filter(Z1, window_size, filter_order)
            else: # fit a line
                result = self.mean_velocity(tol=tol)
                if result['dt'] and \
                    result['dt'] > 0.1 * self.time[-1] and result['p'][0] > 0:
                    if self.calibration._c_drop:
                        c_drop = self.calibration.c_drop(self.frequency)
                    else:
                        c_drop = self.capacitance()[-1] / self.area
                    if self.calibration._c_filler:
                        c_filler = self.calibration.c_filler(self.frequency)
                    else:
                        c_filler = 0
                    x = result['p'][0]*self.time + result['p'][1]
                    C = self.area * (x * (c_drop - c_filler) / \
                                     np.sqrt(self.area) + c_filler)
                    Z1 = 1.0 / (2.0 * math.pi * self.frequency * C)
                    Z1[mlab.find(self.time==result['t_end'])[0]+1:] = \
                        Z1[mlab.find(self.time==result['t_end'])[0]]
                else:
                    Z1 = np.mean(Z1)*np.ones(Z1.shape)
        return Z1

    def force(self, Ly=None):
        '''
        Estimate the applied force (in Newtons) on a drop according to the
        electromechanical model [1].

            Ly is the length of the actuated electrode along the y-axis
                (perpendicular to the direction of motion) in milimeters. By
                default, use the square root of the actuated electrode area,
                i.e.,
                    Ly=Lx=sqrt(Area)
                To get the force normalized by electrode width (i.e., in units
                of N/mm), set Ly=1.0.

        1. Chatterjee et al., "Electromechanical model for actuating liquids in
           a two-plate droplet microfluidic device," Lab on a Chip, no. 9
           (2009): 1219-1229.
        '''
        if self.calibration._c_drop:
            c_drop = self.calibration.c_drop(self.frequency)
        else:
            c_drop = self.capacitance()[-1] / self.area
        if self.calibration._c_filler:
            c_filler = self.calibration.c_filler(self.frequency)
        else:
            c_filler = 0
        if Ly is None:
            Ly = np.sqrt(self.area)
        return 1e3 * Ly * 0.5 * (c_drop - c_filler) * self.V_actuation()**2

    def min_impedance(self):
        return min(self.Z_device())

    def capacitance(self, filter_order=None, window_size=None, tol=0.05):
        '''
        Compute the capacitance of the DMF device _(i.e., dielectric and
        droplet)_ based on the computed impedance value.

        Note: this assumes impedance is purely capacitive load.
        TODO: Is this assumption ok?
        '''
        C = 1.0 / (2.0 * math.pi * self.frequency *
                   self.Z_device(filter_order=filter_order,
                                 window_size=window_size, tol=tol))
        return np.ma.masked_invalid(pd.TimeSeries(C, pd.to_datetime(self.time,
                                                                    unit='s'))
                                    .interpolate(method='time',
                                                 downcast=None).values)

    def x_position(self, filter_order=None, window_size=None, tol=0.05,
                   Lx=None):
        '''
        Calculate $x$-position according to:

               __ | C       |
                           ╲╱ a   ⋅ | - - c_f |
                  | a       |
        x = ──────────────
              c_d - c_f

        where:

         - $C$ is the measured capacitance.
         - $c_f$ is the capacitance of the filler medium per unit area
           _(e.g., air)_.
         - $c_d$ is the capacitance of an electrode completely covered in
           liquid per unit area.
         - $a$ is the area of the actuated electrode(s).

        Note that this equation for $x$ assumes a single drop moving across an
        electrode with a length along the x-axis of Lx. If no value is provided
        for Lx, the electrode is assumed to be square, i.e.,
            Lx=Ly=sqrt(area)
        '''
        if self.calibration._c_drop:
            c_drop = self.calibration.c_drop(self.frequency)
        else:
            c_drop = self.capacitance()[-1] / self.area
        if self.calibration._c_filler:
            c_filler = self.calibration.c_filler(self.frequency)
        else:
            c_filler = 0
        if Lx is None:
            Lx = np.sqrt(self.area)
        return (self.capacitance(filter_order=filter_order,
                                 window_size=window_size, tol=tol) / self.area
                - c_filler) / (c_drop - c_filler) * Lx

    def mean_velocity(self, tol=0.05, Lx=None):
        '''
        Calculate the mean velocity for a step (mm/ms which is equivalent to
        m/s). Fit a line to the capacitance data and get the slope.
        '''
        dx = None
        dt = None
        p = None
        ind = None
        t_end = None

        if self.area == 0:
            return dict(dx=dx, dt=dt, p=p, ind=ind, t_end=t_end)

        x = self.x_position(Lx=Lx)

        # find the first and last valid indices
        ind_start = mlab.find(x.mask==False)[0]
        ind_last = mlab.find(x.mask==False)[-1]

        # if the original x value is within tol % of the final x value, include
        # all samples
        if x[ind_start] > (1 - tol) * x[ind_last] or x[ind_last] < 0:
            ind_stop = ind_last
        else: # otherwise, stop when x reaches (1 - tol) % of it's final value
            ind_stop = mlab.find(x > (1 - tol) * x[ind_last])[0]

        ind = [ind_start, ind_stop]

        # if we have at least 2 valid samples
        if len(ind) >=2:
            dx = np.diff(x[ind])[0]
            dt = np.diff(self.time[ind])[0] # ms

            # suppress polyfit warnings
            with warnings.catch_warnings():
                warnings.simplefilter("ignore")
                # fit a line to the data
                p = np.polyfit(self.time[ind[0]:ind[1]], x[ind[0]:ind[1]], 1)

            # find time when the the line intercepts x[ind_last]
            ind_stop = mlab.find(self.time > \
                                 (x[ind_last] - p[1]) / p[0])
            if len(ind_stop):
                t_end = self.time[ind_stop[0]]
            else:
                t_end = self.time[-1]
        return dict(dx=dx, dt=dt, p=p, ind=ind, t_end=t_end)

    def _get_window_size(self, tol=0.05):
        dt = self.time[1]-self.time[0]
        # calculate the mean velocity
        result = self.mean_velocity(tol=tol)
        window_size = None

        # calculate a default filtering window size
        if result['dt'] and \
            result['dt'] > 0.1 * self.time[-1] and result['p'][0] > 0:

            # get the velocity from the slope of the fit to the capacitance data
            mean_dxdt = result['p'][0]

            # pick a window size that corresponds to the time it takes to cover
            # half the electrode (must be an odd number)
            window_size = np.round(0.5 / (mean_dxdt / np.sqrt(self.area)) / \
                                   dt / 2.0) * 2.0 + 1
        return window_size

    def dxdt(self, filter_order=None, window_size=None, tol=0.05, Lx=None):
        x = self.x_position(Lx=Lx)

        if filter_order and window_size is None:
            window_size = self._get_window_size(tol)

        dt = self.time[1]-self.time[0]

        # if the filter_order or window size is None or if the window size is
        # smaller than filter_order + 2, don't filter
        if filter_order is None or window_size is None or \
            (window_size and window_size < filter_order + 2):
            dx = np.diff(x)
            t = self.time[1:] - dt/2.0
        else: # filter
            t = self.time
            dx = np.zeros(t.shape)
            if window_size:
                # if the window size is less than half the sample length
                if window_size < len(x) / 2:
                    # suppress polyfit warnings
                    with warnings.catch_warnings():
                        warnings.simplefilter("ignore")
                        dx = savgol_filter(x, window_size, filter_order, 1)
                else: # use the average velocity
                    result = self.mean_velocity(tol=tol)
                    mean_dxdt = 0
                    if result['p'] is not None:
                        mean_dxdt = result['p'][0]
                    dx[:mlab.find(t==result['t_end'])[0]+1] = mean_dxdt * dt
            else:
                # otherwise, leave dx = 0
                pass
        return t, np.ma.masked_invalid(dx / dt)


class FeedbackResultsSeries():
    """
    This class stores the impedance results for a series of measurements versus
    another independent variable.
    """
    class_version = str(Version(0, 1))

    def __init__(self, xlabel):
        self.data = []
        self.version = self.class_version
        self.xlabel = xlabel
        self.x = np.zeros(0)
        self.time = []

    def __setstate__(self, state):
        self.__dict__ = state
        self._upgrade()

    def _upgrade(self):
        """
        Upgrade the serialized object if necessary.

        Raises:
            FutureVersionError: file was written by a future version of the
                software.
        """
        logging.debug("[FeedbackResultsSeries]._upgrade()")
        version = Version.fromstring(self.version)
        logging.debug('[FeedbackResultsSeries] version=%s, class_version=%s',
                      str(version), self.class_version)
        if version > Version.fromstring(self.class_version):
            logging.debug('[FeedbackResultsSeries] version>class_version')
            raise FutureVersionError(Version.fromstring(self.class_version),
                                     version)
        elif version < Version.fromstring(self.class_version):
            if version < Version(0, 1):
                self.time = [None]*len(self.data)
                self.version = str(Version(0, 1))
        # else the versions are equal and don't need to be upgraded

    def add_data(self, x, feedback_results):
        self.x = np.concatenate((self.x, [x]))
        self.data.append(feedback_results)
        self.time.append(time.time())

    @property
    def frequency(self):
        return self._concatenate_data_from_member('frequency')

    @property
    def voltage(self):
        return self._concatenate_data_from_member('voltage')

    def V_total(self, *args, **kwargs):
        return self._concatenate_data_from_function('V_total', *args, **kwargs)

    def V_actuation(self, *args, **kwargs):
        return self._concatenate_data_from_function('V_actuation', *args, **kwargs)

    def Z_device(self, *args, **kwargs):
        return self._concatenate_data_from_function('Z_device', *args, **kwargs)

    def force(self, *args, **kwargs):
        return self._concatenate_data_from_function('force', *args, **kwargs)

    def capacitance(self, *args, **kwargs):
        return self._concatenate_data_from_function('capacitance', *args, **kwargs)

    def x_position(self, *args, **kwargs):
        return self._concatenate_data_from_function('x_position', *args, **kwargs)

    def mean_velocity(self, *args, **kwargs):
        return self._concatenate_data_from_function('mean_velocity', *args, **kwargs)

    def dxdt(self, *args, **kwargs):
        return self._concatenate_data_from_function('dxdt', *args, **kwargs)

    def _concatenate_data_from_function(self, fn, *args, **kwargs):
        data = getattr(self.data[0], fn)(*args, **kwargs)
        result = np.array(data.reshape((1, len(data))))
        for row in self.data[1:]:
            data = getattr(row, fn)(*args, **kwargs)
            result = np.concatenate((result, data.reshape((1, len(data)))))
        return np.ma.masked_invalid(result)

    def _concatenate_data_from_member(self, name):
        data = getattr(self.data[0], name)
        result = np.array([data])
        for row in self.data[1:]:
            data = getattr(row, name)
            result = np.concatenate((result, np.array([data])))
        return np.ma.masked_invalid(result)


class FeedbackCalibration():
    class_version = str(Version(0, 3))

    def __init__(self, R_hv=None, C_hv=None, R_fb=None, C_fb=None, c_drop=None,
                 c_filler=None, hw_version=None):
        if R_hv:
            self.R_hv = np.array(R_hv)
        else:
            self.R_hv = np.array([8.7e4, 6.4e5])
        if C_hv:
            self.C_hv = np.array(C_hv)
        else:
            self.C_hv = np.array([1.4e-10, 1.69e-10])
        if R_fb:
            self.R_fb = np.array(R_fb)
        else:
            self.R_fb = np.array([1.14e3, 1e4, 9.3e4, 6.5e5])
        if C_fb:
            self.C_fb = np.array(C_fb)
        else:
            self.C_fb = np.array([3e-14, 3.2e-10, 3.3e-10, 3.4e-10])
        if c_drop:
            self._c_drop = c_drop
        else:
            self._c_drop = None
        if c_filler:
            self._c_filler = c_filler
        else:
            self._c_filler = None
        if hw_version:
            self.hw_version = hw_version
        else:
            self.hw_version = Version(1)
        self.version = self.class_version

    def C_drop(self, frequency):
        '''
        This function has been depreciated. It has been replaced by the
        lowercase version c_drop, which signifies that the capacitance is
        normalized per unit area (i.e., units are F/mm^2).
        '''
        logger.warning('C_drop is depreciated. Use c_drop instead.')
        return self.c_drop(frequency)

    def C_filler(self, frequency):
        '''
        This function has been depreciated. It has been replaced by the
        lowercase version c_filler, which signifies that the capacitance is
        normalized per unit area (i.e., units are F/mm^2).
        '''
        logger.warning('C_filler is depreciated. Use c_filler instead.')
        return self.c_filler(frequency)

    def c_drop(self, frequency):
        '''
        Capacitance of an electrode covered in liquid, normalized per unit
        area (i.e., units are F/mm^2).
        '''
        try:
            return np.interp(frequency,
                             self._c_drop['frequency'],
                             self._c_drop['capacitance']
            )
        except:
            pass
        return self._c_drop

    def c_filler(self, frequency):
        '''
        Capacitance of an electrode covered in filler media (e.g., air or oil),
        normalized per unit area (i.e., units are F/mm^2).
        '''
        try:
            return np.interp(frequency,
                             self._c_filler['frequency'],
                             self._c_filler['capacitance']
            )
        except:
            pass
        return self._c_filler

    def __getstate__(self):
        """Convert numpy arrays to lists for serialization"""
        out = copy.deepcopy(self.__dict__)
        for k, v in out.items():
            if isinstance(v, np.ndarray):
                out[k] = v.tolist()
        return out

    def __setstate__(self, state):
        """Convert lists to numpy arrays after loading serialized object"""
        # rename C_drop and C_filler members
        for k in ['C_drop', 'C_filler']:
            if k in state:
                state['_' + k.lower()] = state[k]
                del state[k]
        # rename _C_drop and _C_filler members to their lowercase versions
        for k in ['_C_drop', '_C_filler']:
            if k in state:
                state[k.lower()] = state[k]
                del state[k]
        self.__dict__ = state
        for k, v in self.__dict__.items():
            if k == 'R_hv' or k == 'C_hv' or k == 'R_fb' or k == 'C_fb':
                self.__dict__[k] = np.array(v)
        if 'version' not in self.__dict__:
            self.version = str(Version(0, 0))
        self._upgrade()

    def _upgrade(self):
        """
        Upgrade the serialized object if necessary.

        Raises:
            FutureVersionError: file was written by a future version of the
                software.
        """
        logging.debug("[FeedbackCalibration]._upgrade()")
        version = Version.fromstring(self.version)
        logging.debug('[FeedbackCalibration] version=%s, class_version=%s',
                      str(version), self.class_version)
        if version > Version.fromstring(self.class_version):
            logging.debug('[FeedbackCalibration] version>class_version')
            raise FutureVersionError(Version.fromstring(self.class_version),
                                     version)
        elif version < Version.fromstring(self.class_version):
            if version < Version(0, 1):
                self._c_filler = None
                self._c_drop = None
                self.version = str(Version(0, 1))
            if version < Version(0, 2):
                self.hw_version = Version(1)
                self.version = str(Version(0, 2))
                logging.info('[FeedbackCalibration] upgrade to version %s',
                             self.version)
            if version < Version(0, 2):
                self.hw_version = Version(1)
                self.version = str(Version(0, 2))
                logging.info('[FeedbackCalibration] upgrade to version %s',
                             self.version)
            if version < Version(0, 3):
                self.version = str(Version(0, 3))
                logging.info('[FeedbackCalibration] upgrade to version %s',
                             self.version)
        # else the versions are equal and don't need to be upgraded


@decorator.decorator
def safe_series_resistor_index_read(f, self, channel, resistor_index=None):
    '''
    This decorator checks the resistor-index from the current context _(i.e.,
    the result of `self.series_resistor_index`)_.  If the resistor-index
    specified by the `resistor_index` keyword argument is different than the
    current context value, the series-resistor-index is temporarily set to the
    value of `resistor_index` to execute the wrapped function before restoring
    back to the original value.
    '''
    if resistor_index is not None:
        original_resistor_index = self.series_resistor_index(channel)
        # Save state of resistor-index
        if resistor_index != original_resistor_index:
            self.set_series_resistor_index(channel, resistor_index)

    value = f(self, channel)

    if (resistor_index is not None and
            resistor_index != original_resistor_index):
        # Restore state of resistor-index
        self.set_series_resistor_index(channel, original_resistor_index)
    return value


@decorator.decorator
def safe_series_resistor_index_write(f, self, channel, value,
                                     resistor_index=None):
    '''
    This decorator checks the resistor-index from the current context _(i.e.,
    the result of `self.series_resistor_index`)_.  If the resistor-index
    specified by the `resistor_index` keyword argument is different than the
    current context value, the series-resistor-index is temporarily set to the
    value of `resistor_index` to execute the wrapped function before restoring
    back to the original value.
    '''
    if resistor_index is not None:
        original_resistor_index = self.series_resistor_index(channel)
        # Save state of resistor-index
        if resistor_index != original_resistor_index:
            self.set_series_resistor_index(channel, resistor_index)

    value = f(self, channel, value)

    if (resistor_index is not None and
            resistor_index != original_resistor_index):
        # Restore state of resistor-index
        self.set_series_resistor_index(channel, original_resistor_index)
    return value


@decorator.decorator
def remote_command(function, self, *args, **kwargs):
    '''
    Catch `RuntimeError` exceptions raised by remote control board firmware
    commands and re-raise as more specific `FirmwareError` exception type,
    which includes command code and return code.
    '''
    try:
        return function(self, *args, **kwargs)
    except RuntimeError, exception:
        error_message = str(exception)
        match = CRE_REMOTE_ERROR.match(error_message)
        if match:
            # Exception message matches format of remote firmware error.
            command_code = int(match.group('command_int'))
            return_code = int(match.group('return_code_int'))
            raise FirmwareError(command_code, return_code)

        match = CRE_REMOTE_COMMAND_ERROR.match(error_message)
        if match:
            # Exception message matches format of remote firmware error.
            command_code = int(match.group('command_int'))
            command_name = NAMES_BY_COMMAND_CODE[command_code]
            raise RuntimeError(CRE_REMOTE_COMMAND_ERROR.sub(command_name,
                                                            error_message))

        # Not a remote firmware error, so raise original exception.
        raise


class DMFControlBoard(Base, SerialDevice):
    def __init__(self):
        Base.__init__(self)
        SerialDevice.__init__(self)
        self.calibration = None
        self.__aref__ = None
        self._number_of_channels = None

    def force_to_voltage(self, force, frequency):
        '''
        Convert a force in uN/mm to voltage.
        '''
        c_drop = self.calibration.c_drop(frequency)

        # if c_filler hasn't been set, assume c_filler = 0
        if self.calibration._c_filler:
            c_filler = self.calibration.c_filler(frequency)
        else:
            c_filler = 0

        return np.sqrt(force * 1e-9/ (0.5 * (c_drop - c_filler)))

    @safe_series_resistor_index_read
    def series_capacitance(self, channel, resistor_index=None):
        '''
        Return the current series capacitance value for the specified channel.

        If `resistor_index` is not specified, the resistor-index from the
        current context _(i.e., the result of `self.series_resistor_index`)_ is
        used.  Otherwise, the series-resistor is temporarily set to the value
        of `resistor_index` to read the capacitance before restoring back to
        the original value.  See definition of
        `safe_series_resistor_index_read` decorator.
        '''
        if resistor_index is None:
            resistor_index = self.series_resistor_index(channel)
        value = self._series_capacitance(channel)
        try:
            if channel == 0:
                self.calibration.C_hv[resistor_index] = value
            else:
                self.calibration.C_fb[resistor_index] = value
        except:
            pass
        return value

    @safe_series_resistor_index_read
    def series_resistance(self, channel, resistor_index=None):
        '''
        Return the current series resistance value for the specified channel.

        If `resistor_index` is not specified, the resistor-index from the
        current context _(i.e., the result of `self.series_resistor_index`)_ is
        used.  Otherwise, the series-resistor is temporarily set to the value
        of `resistor_index` to read the resistance before restoring back to the
        original value.  See definition of `safe_series_resistor_index_read`
        decorator.
        '''
        if resistor_index is None:
            resistor_index = self.series_resistor_index(channel)
        value = self._series_resistance(channel)
        try:
            if channel == 0:
                self.calibration.R_hv[resistor_index] = value
            else:
                self.calibration.R_fb[resistor_index] = value
        except:
            pass
        return value

    @safe_series_resistor_index_write
    def set_series_capacitance(self, channel, value, resistor_index=None):
        '''
        Set the current series capacitance value for the specified channel.

        If `resistor_index` is not specified, the resistor-index from the
        current context _(i.e., the result of `self.series_resistor_index`)_ is
        used.  Otherwise, the series-resistor is temporarily set to the value
        of `resistor_index` to set the capacitance before restoring back to
        the original value.  See definition of
        `safe_series_resistor_index_write` decorator.
        '''
        if resistor_index is None:
            resistor_index = self.series_resistor_index(channel)
        try:
            if channel == 0:
                self.calibration.C_hv[resistor_index] = value
            else:
                self.calibration.C_fb[resistor_index] = value
        except:
            pass
        return self._set_series_capacitance(channel, value)

    @safe_series_resistor_index_write
    def set_series_resistance(self, channel, value, resistor_index=None):
        '''
        Set the current series resistance value for the specified channel.

        If `resistor_index` is not specified, the resistor-index from the
        current context _(i.e., the result of `self.series_resistor_index`)_ is
        used.  Otherwise, the series-resistor is temporarily set to the value
        of `resistor_index` to set the resistance before restoring back to the
        original value.  See definition of `safe_series_resistor_index_write`
        decorator.
        '''
        if resistor_index is None:
            resistor_index = self.series_resistor_index(channel)
        try:
            if channel == 0:
                self.calibration.R_hv[resistor_index] = value
            else:
                self.calibration.R_fb[resistor_index] = value
        except:
            pass
        return self._set_series_resistance(channel, value)

    @property
    def config_version(self):
        return tuple(
            self.persistent_read_multibyte(self.PERSISTENT_CONFIG_SETTINGS,
                                           count=3, dtype=np.uint16))

    @remote_command
    def connect(self, port=None, baud_rate=115200):
        if port:
            logger.info("Try connecting to port %s..." % port)
            Base.connect(self, port, baud_rate)
            self.port = port
        else:
            self.get_port(baud_rate)
            return self.RETURN_OK

        self._i2c_devices = {}

        name = self.name()
        version = self.hardware_version()
        firmware = self.software_version()
        serial_number_string = ""
        try:
            serial_number_string = ", S/N %03d" % self.serial_number
        except: # need to catch exceptions here because this call will generate
                # an error on old firmware which will prevent us from getting
                # the opportunity to apply a firmware update.
            pass
        logger.info("Connected to %s v%s (Firmware: %s%s)" %
                    (name, version, firmware, serial_number_string))

        logger.info("Poll control board for series resistors and "
                    "capacitance values.")

        self._read_calibration_data()

        try:
            self.__aref__ = self._aref()
            logger.info("Analog reference = %.2f V" % self.__aref__)
        except: # need to catch exceptions here because this call will generate
                # an error on old firmware which will prevent us from getting
                # the opportunity to apply a firmware update.
            pass

        # Check VGND for both analog channels
        expected = 2**10/2
        v = {}
        channels = [0,1]
        damaged = []
        for channel in channels:
            try:
                v[channel] = np.mean(self.analog_reads(channel, 10))
                logger.info("A%d VGND = %.2f V (%.2f%% of Aref)" % (
                    channel, self.__aref__ * v[channel] / (2**10),
                    100.0 * v[channel] / (2**10)))
                # Make sure that the VGND is close to the expected value;
                # otherwise, the op-amp may be damaged (expected error
                # is <= 10%).
                if np.abs(v[channel] - expected) / expected > .1:
                    damaged.append(channel)
            except: # need to catch exceptions here because this call will
                    # generate an error on old firmware which will prevent us
                    # from getting the opportunity to apply a firmware update.
                break

        self._i2c_scan()

        if damaged:
            if len(damaged) == 1:
                msg = "Analog channel %d appears" % damaged[0]
            else:
                msg = "Analog channels %s appear" % damaged
            raise BadVGND(msg + " to be damaged. You may need to replace the "
                          "op-amp on the control board.")

        return self.RETURN_OK

    def _i2c_scan(self):
        logger.info("Scan i2c bus:")
        # scan for devices on the i2c bus
        try:
            for address in self.i2c_scan():
                try:
                    node = BaseNode(self, address)
                    description = ("%s v%s (Firmware v%s, S/N %03d)" %
                        (node.name(), node.hardware_version(),
                         node.software_version(), node.serial_number))
                except:
                    description = "?" % address
                self._i2c_devices[address] = description
                logger.info("\t%d: %s" % (address, description))
        except: # need to catch exceptions here because this call will
                # generate an error on old firmware which will prevent us
                # from getting the opportunity to apply a firmware update.
            pass

    def _read_calibration_data(self):
        R_hv = self.a0_series_resistance
        C_hv = self.a0_series_capacitance
        R_fb = self.a1_series_resistance
        C_fb = self.a1_series_capacitance
        logger.info("R_hv=%s" % R_hv)
        logger.info("C_hv=%s" % C_hv)
        logger.info("R_fb=%s" % R_fb)
        logger.info("C_fb=%s" % C_fb)
        self.calibration = FeedbackCalibration(R_hv, C_hv, R_fb, C_fb,
                                               hw_version=
                                               Version.fromstring
                                               (self
                                                .hardware_version()))

    def persistent_write(self, address, byte, refresh_config=False):
        '''
        Write a single byte to an address in persistent memory.

        If refresh_config is True, load_config() is called afterward to
        refresh the configuration settings.
        '''
        self._persistent_write(address, byte)
        if refresh_config:
            self.load_config(False)

    def persistent_read_multibyte(self, address, count=None,
                                  dtype=np.uint8):
        nbytes = np.dtype(dtype).itemsize
        if count is not None:
            nbytes *= count

        # Read enough bytes starting at specified address to match the
        # requested number of the specified data type.
        data_bytes = np.array([self.persistent_read(address + i)
                               for i in xrange(nbytes)], dtype=np.uint8)

        # Cast byte array as array of specified data type.
        result = data_bytes.view(dtype)

        # If no count was specified, we return a scalar value rather than the
        # resultant array.
        if count is None:
            return result[0]
        return result

    def persistent_write_multibyte(self, address, data, refresh_config=False):
        '''
        Write multiple bytes to an address in persistent memory.

        If refresh_config is True, load_config() is called afterward to
        refresh the configuration settings.
        '''
        for i, byte in enumerate(data.view(np.uint8)):
            self.persistent_write(address + i, int(byte))
        if refresh_config:
            self.load_config(False)

    @property
    def baud_rate(self):
        return self.persistent_read_multibyte(
            self.PERSISTENT_BAUD_RATE_ADDRESS, dtype=np.uint32)

    @baud_rate.setter
    def baud_rate(self, value):
        self.persistent_write_multibyte(self.PERSISTENT_BAUD_RATE_ADDRESS,
                                        np.array([value], dtype=np.uint32),
                                        True)
        self.__baud_rate = value

    @property
    def serial_number(self):
        return self.persistent_read_multibyte(
            self.PERSISTENT_SERIAL_NUMBER_ADDRESS, dtype=np.uint32)

    @serial_number.setter
    def serial_number(self, value):
        self.persistent_write_multibyte(self.PERSISTENT_SERIAL_NUMBER_ADDRESS,
                                        np.array([value], dtype=np.uint32),
                                        True)
        self.__serial_number = value

    @property
    def voltage_tolerance(self):
        if not hasattr(self, '__voltage_tolerance'):
            self.__voltage_tolerance = self.persistent_read_multibyte(
                self.PERSISTENT_VOLTAGE_TOLERANCE, dtype=np.float32)
        return self.__voltage_tolerance

    @voltage_tolerance.setter
    def voltage_tolerance(self, value):
        self.persistent_write_multibyte(self.PERSISTENT_VOLTAGE_TOLERANCE,
                                        np.array([value], dtype=np.float32),
                                        True)
        self.__voltage_tolerance = value

    @property
    def use_antialiasing_filter(self):
        return self.persistent_read(self.PERSISTENT_USE_ANTIALIASING_FILTER)

    @use_antialiasing_filter.setter
    def use_antialiasing_filter(self, value):
        return self.persistent_write(self.PERSISTENT_USE_ANTIALIASING_FILTER,
                                     value, True)

    @property
    def min_waveform_frequency(self):
        if not hasattr(self, '__min_waveform_frequency'):
            self.__min_waveform_frequency = self.persistent_read_multibyte(
                self.PERSISTENT_MIN_WAVEFORM_FREQUENCY, dtype=np.float32)
        return self.__min_waveform_frequency

    @min_waveform_frequency.setter
    def min_waveform_frequency(self, value):
        self.persistent_write_multibyte(self.PERSISTENT_MIN_WAVEFORM_FREQUENCY,
                                        np.array([value], dtype=np.float32),
                                        True)
        self.__min_waveform_frequency = value

    @property
    def max_waveform_frequency(self):
        if not hasattr(self, '__max_waveform_frequency'):
            self.__max_waveform_frequency = self.persistent_read_multibyte(
                self.PERSISTENT_MAX_WAVEFORM_FREQUENCY, dtype=np.float32)
        return self.__max_waveform_frequency

    @max_waveform_frequency.setter
    def max_waveform_frequency(self, value):
        self.persistent_write_multibyte(self.PERSISTENT_MAX_WAVEFORM_FREQUENCY,
                                        np.array([value], dtype=np.float32),
                                        True)
        self.__max_waveform_frequency = value

    @property
    def max_waveform_voltage(self):
        if not hasattr(self, '__max_waveform_voltage'):
            self.__max_waveform_voltage = self.persistent_read_multibyte(
                self.PERSISTENT_MAX_WAVEFORM_VOLTAGE, dtype=np.float32)
        return self.__max_waveform_voltage

    @max_waveform_voltage.setter
    def max_waveform_voltage(self, value):
        self.persistent_write_multibyte(self.PERSISTENT_MAX_WAVEFORM_VOLTAGE,
                                        np.array([value], dtype=np.float32),
                                        True)
        self.__max_waveform_voltage = value

    @property
    @remote_command
    def state_of_all_channels(self):
        return np.array(Base.state_of_all_channels(self))

    def set_state_of_all_channels(self, state):
        self.state_of_all_channels = state

    @state_of_all_channels.setter
    @remote_command
    def state_of_all_channels(self, state):
        state_ = uint8_tVector()
        for i in range(0, len(state)):
            state_.append(int(state[i]))
        Base.set_state_of_all_channels(self, state_)

    @property
    def default_pin_modes(self):
        pin_modes = []
        for i in range(0, 53 / 8 + 1):
            mode = self.persistent_read(self.PERSISTENT_PIN_MODE_ADDRESS + i)
            for j in range(0, 8):
                if i * 8 + j <= 53:
                    pin_modes.append(~mode >> j & 0x01)
        return pin_modes

    def set_default_pin_modes(self, pin_modes):
        self.default_pin_modes = pin_modes

    @default_pin_modes.setter
    def default_pin_modes(self, pin_modes):
        for i in range(0, 53 / 8 + 1):
            mode = 0
            for j in range(0, 8):
                if i * 8 + j <= 53:
                    mode += pin_modes[i * 8 + j] << j
            self.persistent_write(self.PERSISTENT_PIN_MODE_ADDRESS + i, ~mode &
                                  0xFF, True)

    @property
    def default_pin_states(self):
        pin_states = []
        for i in range(0, 53 / 8 + 1):
            state = self.persistent_read(self.PERSISTENT_PIN_STATE_ADDRESS + i)
            for j in range(0, 8):
                if i * 8 + j <= 53:
                    pin_states.append(~state >> j & 0x01)
        return pin_states

    def set_default_pin_states(self, pin_states):
        self.default_pin_states = pin_states

    @default_pin_states.setter
    def default_pin_states(self, pin_states):
        for i in range(0, 53 / 8 + 1):
            state = 0
            for j in range(0, 8):
                if i * 8 + j <= 53:
                    state += pin_states[i * 8 + j] << j
            self.persistent_write(self.PERSISTENT_PIN_STATE_ADDRESS + i, ~state
                                  & 0xFF, True)

    @remote_command
    def analog_reads(self, pins, n_samples):
        pins_ = uint8_tVector()
        if hasattr(pins, '__len__'):
            for i in range(0, len(pins)):
                pins_.append(pins[i])
        else:
            pins_.append(pins)
        return np.array(Base.analog_reads(self, pins_, n_samples))

    @remote_command
    def number_of_channels(self):
        # check for cached value
        if self._number_of_channels is None:
            self._number_of_channels = Base.number_of_channels(self)
        return self._number_of_channels

    @remote_command
    def measure_impedance_non_blocking(self,
                                       sampling_window_ms,
                                       n_sampling_windows,
                                       delay_between_windows_ms,
                                       interleave_samples,
                                       rms,
                                       state):
        state_ = uint8_tVector()
        for i in range(0, len(state)):
            state_.append(int(state[i]))
        Base.measure_impedance_non_blocking(self,
                                            sampling_window_ms,
                                            n_sampling_windows,
                                            delay_between_windows_ms,
                                            interleave_samples,
                                            rms,
                                            state_)

    def impedance_buffer_to_feedback_result(self, buffer):
        amplifier_gain = buffer[-1]
        vgnd_hv = buffer[-2]
        vgnd_fb = buffer[-3]
        dt_ms = buffer[-4]
        buffer = buffer[:-4]
        V_hv = buffer[0::4] / (64*1023.0) * self.__aref__ / 2.0 / np.sqrt(2)
        hv_resistor = buffer[1::4].astype(int)
        V_fb = buffer[2::4] / (64*1023.0) * self.__aref__ / 2.0 / np.sqrt(2)
        fb_resistor = buffer[3::4].astype(int)
        voltage = self.waveform_voltage()
        frequency = self.waveform_frequency()
        return FeedbackResults(voltage, frequency, dt_ms, V_hv, hv_resistor,
                               V_fb, fb_resistor, self.calibration,
                               amplifier_gain=amplifier_gain,
                               vgnd_hv=vgnd_hv, vgnd_fb=vgnd_fb)

    @remote_command
    def get_impedance_data(self):
        buffer = np.array(Base.get_impedance_data(self))
        return self.impedance_buffer_to_feedback_result(buffer)

    @remote_command
    def measure_impedance(self,
                          sampling_window_ms,
                          n_sampling_windows,
                          delay_between_windows_ms,
                          interleave_samples,
                          rms,
                          state):
        '''
        Measure voltage across load of each of the following control board
        feedback circuits:

         - Reference _(i.e., attenuated high-voltage amplifier output)_.
         - Load _(i.e., voltage across DMF device)_.

        The measured voltage _(i.e., `V2`)_ can be used to compute the
        impedance of the measured load, the input voltage _(i.e., `V1`)_, etc.
        '''
        state_ = uint8_tVector()
        for i in range(0, len(state)):
            state_.append(int(state[i]))

        buffer = np.array(Base.measure_impedance(self,
                                                 sampling_window_ms,
                                                 n_sampling_windows,
                                                 delay_between_windows_ms,
                                                 interleave_samples,
                                                 rms,
                                                 state_))
        return self.impedance_buffer_to_feedback_result(buffer)

    @remote_command
    def i2c_scan(self):
        return np.array(Base.i2c_scan(self))

    @remote_command
    def i2c_write(self, address, data):
        data_ = uint8_tVector()
        for i in range(0, len(data)):
            data_.append(int(data[i]))
        Base.i2c_write(self, address, data_)

    @remote_command
    def i2c_read(self, address, n_bytes_to_read):
        return np.array(Base.i2c_read(self, address, n_bytes_to_read))

    @remote_command
    def i2c_send_command(self, address, cmd, data, delay_ms=100):
        data_ = uint8_tVector()
        for i in range(0, len(data)):
            data_.append(int(data[i]))
        return np.array(Base.i2c_send_command(self, address, cmd, data_,
                                              delay_ms))

    def test_connection(self, port, baud_rate):
        logger.info("test_connection(%s, %d)" % (port, baud_rate))
        try:
            if self.connect(port, baud_rate) == self.RETURN_OK:
                return True
        except Exception, why:
            logger.info('On port %s, %s' % (port, why))
        return False

    def flash_firmware(self, hardware_version=None):
        logger.info("[DMFControlBoard].flash_firmware()")
        reconnect = self.connected()
        if reconnect:
            if not hardware_version:
                hardware_version = Version.fromstring(self.hardware_version())
            self.disconnect()
        try:
            hex_path = package_path().joinpath('firmware', 'mega2560', '%s_%s'
                                               % (hardware_version.major,
                                                  hardware_version.minor),
                                               'dmf_control_board.hex')
            logger.info("hex_path=%s" % hex_path)

            context = auto_context()
            board = Board(context, 'mega2560')
            uploader = Uploader(board)

            logger.info("flashing firmware: hardware version %s"
                        % (hardware_version))

            logger.info(uploader.upload(hex_path.abspath(), self.port))

            if reconnect:
                # need to sleep here, otherwise reconnect fails
                time.sleep(.1)
                self.connect(self.port)
        except Exception, why:
            print "Exception flashing firmware: %s" % why
            if reconnect:
                self.connect(self.port)
            raise

    @property
    def PERSISTENT_AREF_ADDRESS(self):
        hardware_version = self.hardware_version()
        if (hardware_version == '1.0' or hardware_version == '1.1' or
                hardware_version == '1.2'):
            return self.PERSISTENT_CONFIG_SETTINGS + 6
        else:
            raise PersistentSettingDoesNotExist()

    @property
    def PERSISTENT_SWITCHING_BOARD_I2C_ADDRESS(self):
        hardware_version = self.hardware_version()
        if (hardware_version == '1.0' or hardware_version == '1.1' or
                hardware_version == '1.2'):
            return self.PERSISTENT_CONFIG_SETTINGS + 7
        else:
            return self.PERSISTENT_CONFIG_SETTINGS + 6

    @property
    def PERSISTENT_WAVEOUT_GAIN_1_ADDRESS(self):
        hardware_version = self.hardware_version()
        if (hardware_version == '1.0' or hardware_version == '1.1' or
                hardware_version == '1.2'):
            return self.PERSISTENT_CONFIG_SETTINGS + 8
        elif hardware_version == '1.3':
            return self.PERSISTENT_CONFIG_SETTINGS + 7
        else:
            raise PersistentSettingDoesNotExist()

    @property
    def PERSISTENT_VGND_ADDRESS(self):
        hardware_version = self.hardware_version()
        if (hardware_version == '1.0' or hardware_version == '1.1' or
                hardware_version == '1.2'):
            return self.PERSISTENT_CONFIG_SETTINGS + 9
        elif hardware_version == '1.3':
            return self.PERSISTENT_CONFIG_SETTINGS + 8
        else:
            raise PersistentSettingDoesNotExist()

    @property
    def PERSISTENT_SIGNAL_GENERATOR_BOARD_I2C_ADDRESS(self):
        hardware_version = self.hardware_version()
        if hardware_version >= '2.0':
            return self.PERSISTENT_CONFIG_SETTINGS + 7
        else:
            raise PersistentSettingDoesNotExist()

    @property
    def PERSISTENT_VOLTAGE_TOLERANCE(self):
        hardware_version = self.hardware_version()
        if (hardware_version == '1.0' or hardware_version == '1.1' or
                hardware_version == '1.2'):
            return self.PERSISTENT_CONFIG_SETTINGS + 62
        elif hardware_version == '1.3':
            return self.PERSISTENT_CONFIG_SETTINGS + 61
        else:  # hardware_version >= 2.0
            return self.PERSISTENT_CONFIG_SETTINGS + 76

    @property
    def PERSISTENT_USE_ANTIALIASING_FILTER(self):
        hardware_version = self.hardware_version()
        if (hardware_version == '1.0' or hardware_version == '1.1' or
                hardware_version == '1.2'):
            return self.PERSISTENT_CONFIG_SETTINGS + 66
        elif hardware_version == '1.3':
            return self.PERSISTENT_CONFIG_SETTINGS + 65
        else:  # hardware_version >= 2.0
            return self.PERSISTENT_CONFIG_SETTINGS + 80

    @property
    def PERSISTENT_MIN_WAVEFORM_FREQUENCY(self):
        hardware_version = self.hardware_version()
        if (hardware_version == '1.0' or hardware_version == '1.1' or
                hardware_version == '1.2'):
            return self.PERSISTENT_CONFIG_SETTINGS + 67
        elif hardware_version == '1.3':
            return self.PERSISTENT_CONFIG_SETTINGS + 66
        else:  # hardware_version >= 2.0
            return self.PERSISTENT_CONFIG_SETTINGS + 81

    @property
    def PERSISTENT_MAX_WAVEFORM_FREQUENCY(self):
        hardware_version = self.hardware_version()
        if (hardware_version == '1.0' or hardware_version == '1.1' or
                hardware_version == '1.2'):
            return self.PERSISTENT_CONFIG_SETTINGS + 71
        elif hardware_version == '1.3':
            return self.PERSISTENT_CONFIG_SETTINGS + 70
        else:  # hardware_version >= 2.0
            return self.PERSISTENT_CONFIG_SETTINGS + 85

    @property
    def PERSISTENT_MAX_WAVEFORM_VOLTAGE(self):
        hardware_version = self.hardware_version()
        if (hardware_version == '1.0' or hardware_version == '1.1' or
                hardware_version == '1.2'):
            return self.PERSISTENT_CONFIG_SETTINGS + 75
        elif hardware_version == '1.3':
            return self.PERSISTENT_CONFIG_SETTINGS + 74
        else:  # hardware_version >= 2.0
            return self.PERSISTENT_CONFIG_SETTINGS + 89

    @property
    def auto_adjust_amplifier_gain(self):
        return self._auto_adjust_amplifier_gain()

    @auto_adjust_amplifier_gain.setter
    def auto_adjust_amplifier_gain(self, value):
        return self._set_auto_adjust_amplifier_gain(value)

    @property
    def amplifier_gain(self):
        return self._amplifier_gain()

    @amplifier_gain.setter
    def amplifier_gain(self, value):
        return self._set_amplifier_gain(value)

    @property
    def aref(self):
        return self.persistent_read(self.PERSISTENT_AREF_ADDRESS)

    @aref.setter
    def aref(self, value):
        return self.persistent_write(self.PERSISTENT_AREF_ADDRESS, value,
                                     True)

    @property
    def switching_board_i2c_address(self):
        return self.persistent_read(self
                                    .PERSISTENT_SWITCHING_BOARD_I2C_ADDRESS)

    @switching_board_i2c_address.setter
    def switching_board_i2c_address(self, value):
        return self.persistent_write(self
                                     .PERSISTENT_SWITCHING_BOARD_I2C_ADDRESS,
                                     value,
                                     True)

    @property
    def waveout_gain_1(self):
        return self.persistent_read(self.PERSISTENT_WAVEOUT_GAIN_1_ADDRESS)

    @waveout_gain_1.setter
    def waveout_gain_1(self, value):
        return self.persistent_write(self.PERSISTENT_WAVEOUT_GAIN_1_ADDRESS,
                                     value,
                                     True)

    @property
    def vgnd(self):
        return self.persistent_read(self.PERSISTENT_VGND_ADDRESS)

    @vgnd.setter
    def vgnd(self, value):
        return self.persistent_write(self.PERSISTENT_VGND_ADDRESS, value, True)

    @property
    def signal_generator_board_i2c_address(self):
        return self.persistent_read(
            self.PERSISTENT_SIGNAL_GENERATOR_BOARD_I2C_ADDRESS)

    @signal_generator_board_i2c_address.setter
    def signal_generator_board_i2c_address(self, value):
        return self.persistent_write(
            self.PERSISTENT_SIGNAL_GENERATOR_BOARD_I2C_ADDRESS, value, True)

    def read_all_series_channel_values(self, f, channel):
        '''
        Return all values for the specified channel of the type corresponding
        to the function `f`, where `f` is either `self.series_resistance` or
        `self.series_capacitance`.
        '''
        values = []
        channel_max_param_count = [3, 5]
        for i in range(channel_max_param_count[channel]):
            try:
                values.append(f(channel, i))
            except RuntimeError:
                break
        return values

    def write_all_series_channel_values(self, read_f, write_f, channel,
                                        values):
        '''
        Return all values for the specified channel of the type corresponding
        to the function `f`, where `f` is either `self.series_resistance` or
        `self.series_capacitance`.
        '''

        # Create a copy of the new values we intend to write. Otherwise, if
        # `values` is a reference to the calibration object owned by the
        # control board, it can be overwritten in the following step which will
        # prevent the update.
        #
        # See http://microfluidics.utoronto.ca/trac/dropbot/ticket/81
        values = copy.deepcopy(values)

        # Read the current values, and only update the values that are
        # different.
        original_values = self.read_all_series_channel_values(read_f, channel)

        # Make sure that the number of supplied values matches the number of
        # corresponding values read from the channel.
        assert(len(values) == len(original_values))

        for i in range(len(original_values)):
            if values[i] != original_values[i]:
                write_f(channel, values[i], i)

    @property
    def a0_series_resistance(self):
        return self.read_all_series_channel_values(self.series_resistance, 0)

    @a0_series_resistance.setter
    def a0_series_resistance(self, values):
        return self.write_all_series_channel_values(self.series_resistance,
                                                    self.set_series_resistance,
                                                    0, values)

    @property
    def a0_series_capacitance(self):
        return self.read_all_series_channel_values(self.series_capacitance, 0)

    @a0_series_capacitance.setter
    def a0_series_capacitance(self, values):
        return self.write_all_series_channel_values(self.series_capacitance,
                                                    self
                                                    .set_series_capacitance,
                                                    0, values)

    @property
    def a1_series_resistance(self):
        return self.read_all_series_channel_values(self.series_resistance, 1)

    @a1_series_resistance.setter
    def a1_series_resistance(self, values):
        return self.write_all_series_channel_values(self.series_resistance,
                                                    self.set_series_resistance,
                                                    1, values)

    @property
    def a1_series_capacitance(self):
        return self.read_all_series_channel_values(self.series_capacitance, 1)

    @a1_series_capacitance.setter
    def a1_series_capacitance(self, values):
        return self.write_all_series_channel_values(self.series_capacitance,
                                                    self
                                                    .set_series_capacitance,
                                                    1, values)

    @property
    def config_attribute_names(self):
        return ['aref', 'waveout_gain_1', 'vgnd', 'a0_series_resistance',
                'a0_series_capacitance', 'a1_series_resistance',
                'a1_series_capacitance', 'signal_generator_board_i2c_address',
                'auto_adjust_amplifier_gain', 'amplifier_gain',
                'switching_board_i2c_address', 'voltage_tolerance',
                'min_waveform_frequency', 'max_waveform_frequency',
                'max_waveform_voltage', 'use_antialiasing_filter']

    def reset_config_to_defaults(self):
        self._reset_config_to_defaults()
        self._read_calibration_data()

    def read_config(self):
        except_types = (PersistentSettingDoesNotExist, )
        return OrderedDict([(a, safe_getattr(self, a, except_types))
                            for a in self.config_attribute_names])

    def write_config(self, config):
        device_config = self.read_config()
        common_keys = set(config.keys()).intersection(device_config.keys())
        for k in device_config.keys():
            if k in common_keys and (device_config[k] is not None and
                                     config[k] is not None):
                setattr(self, k, config[k])

    def debug_string(self):
        return "".join(map(chr, self.debug_buffer()))
