Fix problems with the transition to the new nested-Config h5config package.
[calibcant.git] / calibcant / vib_analyze.py
old mode 100755 (executable)
new mode 100644 (file)
index f7d8033..bf74558
-#!/usr/bin/python
-#
 # calibcant - tools for thermally calibrating AFM cantilevers
 #
-# Copyright (C) 2007,2008, William Trevor King
+# Copyright (C) 2008-2011 W. Trevor King <wking@drexel.edu>
 #
-# 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 file is part of calibcant.
 #
-# 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.
+# calibcant is free software: you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation, either
+# version 3 of the License, or (at your option) any later version.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA
-# 02111-1307, USA.
+# calibcant 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 Lesser General Public License for more details.
 #
-# The author may be contacted at <wking@drexel.edu> on the Internet, or
-# write to Trevor King, Drexel University, Physics Dept., 3141 Chestnut St.,
-# Philadelphia PA 19104, USA.
+# You should have received a copy of the GNU Lesser General Public
+# License along with calibcant.  If not, see
+# <http://www.gnu.org/licenses/>.
 
-"""
-Separate the more general vib_analyze() from the other vib_*()
-functions in calibcant.  Also provide a command line interface
-for analyzing data acquired through other workflows.
+"""Thermal vibration analysis.
+
+Separate the more general `vib_analyze()` from the other `vib_*()`
+functions in calibcant.
 
 The relevent physical quantities are :
- Vphoto   The photodiode vertical deflection voltage (what we measure)
+  Vphoto   The photodiode vertical deflection voltage (what we measure)
+
+>>> import os
+>>> from pprint import pprint
+>>> import random
+>>> import tempfile
+>>> import numpy
+>>> from .config import VibrationConfig
+>>> from h5config.storage.hdf5 import pprint_HDF5
+>>> from pypiezo.test import get_piezo_config
+>>> from pypiezo.base import convert_volts_to_bits
+
+>>> fd,filename = tempfile.mkstemp(suffix='.h5', prefix='calibcant-')
+>>> os.close(fd)
+
+>>> piezo_config = get_piezo_config()
+>>> vibration_config = VibrationConfig()
+>>> vibration_config['frequency'] = 50e3
+
+We'll be generating a test vibration time series with the following
+parameters.  Make sure these are all floats to avoid accidental
+integer division in later steps.
+
+>>> m = 5e-11       # kg
+>>> gamma = 1.6e-6  # N*s/m
+>>> k = 0.05        # N/m
+>>> T = 1/vibration_config['frequency']
+>>> T  # doctest: +ELLIPSIS
+2...e-05
+>>> N = int(2**15)  # count
+>>> F_sigma = 1e3   # N
+
+where `T` is the sampling period, `N` is the number of samples, and
+`F_sigma` is the standard deviation of the white-noise external force.
+Note that the resonant frequency is less than the Nyquist frequency so
+we don't have to worry too much about aliasing.
+
+>>> w0 = numpy.sqrt(k/m)
+>>> f0 = w0/(2*numpy.pi)
+>>> f0  # doctest: +ELLIPSIS
+5032.9...
+>>> f_nyquist = vibration_config['frequency']/2
+>>> f_nyquist  # doctest: +ELLIPSIS
+25000.0...
+
+The damping ratio is
+
+>>> damping = gamma / (2*m*w0)
+>>> damping  # doctest: +ELLIPSIS
+0.505...
+
+The quality factor is 
+
+>>> Q = m*w0 / gamma
+>>> Q  # doctest: +ELLIPSIS
+0.988...
+>>> (1 / (2*damping)) / Q  # doctest: +ELLIPSIS
+1.000...
+
+We expect the white-noise power spectral density (PSD) to be a flat
+line at
+
+>>> F0 = F_sigma**2 * 2 * T
+
+because the integral from `0` `1/2T` should be `F_sigma**2`.
+
+The expected time series PSD parameters are
+
+>>> A = f0
+>>> B = gamma/(m*2*numpy.pi)
+>>> C = F0/(m**2*(2*numpy.pi)**4)
+
+Simulate a time series with the proper PSD using center-differencing.
+
+  m\ddot{x} + \gamma \dot{x} + kx = F
+
+  m \frac{x_{i+1} - 2x_i + x_{i-1}}{T**2}
+    + \gamma \frac{x_{i+1}-x_{i-1}}{T}
+    + kx_i = F_i
+
+  a x_{i+1} + b x_{i} + c x_{i-1} = F_i
+
+where `T` is the sampling period, `i=t/T` is the measurement index,
+`a=m/T**2+gamma/2T`, `b=-2m/T**2+k`, and `c=m/T**2-gamma/2T`.
+Rearranging and shifting to `j=i-1`
+
+  x_j = \frac{F_{i-1} - bx_{i-1} - cx_{i-2}}{a}
+
+>>> a = m/T**2 + gamma/(2*T)
+>>> b = -2*m/T**2 + k
+>>> c = m/T**2 - gamma/(2*T)
+>>> x = numpy.zeros((N+2,), dtype=numpy.float)  # two extra initial points
+>>> F = numpy.zeros((N,), dtype=numpy.float)
+>>> for i in range(2, x.size):
+...     Fp = random.gauss(mu=0, sigma=F_sigma)
+...     F[i-2] = Fp
+...     xp = x[i-1]
+...     xpp = x[i-2]
+...     x[i] = (Fp - b*xp - c*xpp)/a
+>>> x = x[2:]  # drop the initial points
+
+Convert the simulated data to bits.
+
+>>> deflection = x
+>>> deflection_bits = convert_volts_to_bits(
+...     piezo_config.select_config('inputs', 'deflection'), x)
+
+Analyze the simulated data.
+
+>>> naive_vibration = vib_analyze_naive(deflection)
+>>> naive_vibration  # doctest: +SKIP
+136871517.43486854
+>>> abs(naive_vibration / 136.9e6 - 1) < 0.1
+True
+
+>>> processed_vibration = vib_analyze(
+...     deflection_bits, vibration_config,
+...     piezo_config.select_config('inputs', 'deflection'))
+>>> processed_vibration  # doctest: +SKIP
+136457906.25574699
+
+>>> vib_plot(deflection=deflection_bits, vibration_config=vibration_config)
+>>> vib_save(filename=filename, group='/vibration/',
+...     raw_vibration=deflection_bits, vibration_config=vibration_config,
+...     deflection_channel_config=piezo_config.select_config(
+...         'inputs', 'deflection'),
+...     processed_vibration=processed_vibration)
+
+>>> pprint_HDF5(filename)  # doctest: +ELLIPSIS, +REPORT_UDIFF
+/
+  /vibration
+    /vibration/config
+      /vibration/config/deflection
+        <HDF5 dataset "channel": shape (), type "<i4">
+          0
+        <HDF5 dataset "conversion-coefficients": shape (2,), type "<i4">
+          [0 1]
+        <HDF5 dataset "conversion-origin": shape (), type "<i4">
+          0
+        <HDF5 dataset "device": shape (), type "|S12">
+          /dev/comedi0
+        <HDF5 dataset "inverse-conversion-coefficients": shape (2,), type "<i4">
+          [0 1]
+        <HDF5 dataset "inverse-conversion-origin": shape (), type "<i4">
+          0
+        <HDF5 dataset "maxdata": shape (), type "<i4">
+          100
+        <HDF5 dataset "name": shape (), type "|S10">
+          deflection
+        <HDF5 dataset "range": shape (), type "<i4">
+          1
+        <HDF5 dataset "subdevice": shape (), type "<i4">
+          -1
+      /vibration/config/vibration
+        <HDF5 dataset "chunk-size": shape (), type "<i4">
+          2048
+        <HDF5 dataset "frequency": shape (), type "<f8">
+          50000.0
+        <HDF5 dataset "maximum-fit-frequency": shape (), type "<f8">
+          25000.0
+        <HDF5 dataset "minimum-fit-frequency": shape (), type "<f8">
+          500.0
+        <HDF5 dataset "model": shape (), type "|S12">
+          Breit-Wigner
+        <HDF5 dataset "overlap": shape (), type "|b1">
+          False
+        <HDF5 dataset "sample-time": shape (), type "<i4">
+          1
+        <HDF5 dataset "window": shape (), type "|S4">
+          Hann
+    <HDF5 dataset "processed": shape (), type "<f8">
+      ...
+    /vibration/raw
+      <HDF5 dataset "deflection": shape (32768,), type "<f8">
+        [...]
+
+>>> (raw_vibration,vibration_config,deflection_channel_config,
+...  processed_vibration) = vib_load(
+...     filename=filename, group='/vibration/')
+
+>>> processed_vibration  # doctest: +SKIP
+136457906.25574699
+>>> abs(processed_vibration / 136.5e6 - 1) < 0.1
+True
+
+>>> os.remove(filename)
 """
 
-import os, time, numpy
-import GnuplotBiDir  # used for fitting lorentzian
-import common # common module for the calibcant package
-import config # config module for the calibcant package
-import data_logger
-import FFT_tools
+import os as _os
+import time as _time
 
-def vib_analyze_naive(deflection_bits, Vphoto_in2V=config.Vphoto_in2V,
-                      zeroVphoto_bits=config.zeroVphoto_bits) :
-    """
-    Calculate the variance of the raw data, and convert it to Volts**2.
-    This method is simple and easy to understand, but it highly succeptible
-    to noise, drift, etc.
-    
-    Vphoto_in2V is a function converting Vphoto values from bits to Volts.
-    This function is assumed linear, see bump_analyze().
-    zeroVphoto_bits is the bit value corresponding to Vphoto = zero Volts.
-    """
-    Vphoto_std_bits = deflection_bits.std()
-    Vphoto_std = Vphoto_in2V(Vphoto_std_bits + zpiezo_zeroV_in)
-    return Vphoto_std**2
-    
-def vib_analyze(deflection_bits, freq, minFreq=500, maxFreq=7000,
-                chunk_size=4096, overlap=True, window=FFT_tools.window_hann,
-                Vphoto_in2V=config.Vphoto_in2V, plotVerbose=False) :
-    """
-    Calculate the variance in the raw data due to the cantilever's 
-    thermal oscillation and convert it to Volts**2.
+import h5py as _h5py
+import numpy as _numpy
+from scipy.optimize import leastsq as _leastsq
+
+try:
+    import matplotlib as _matplotlib
+    import matplotlib.pyplot as _matplotlib_pyplot
+    import time as _time  # for timestamping lines on plots
+except (ImportError, RuntimeError), e:
+    _matplotlib = None
+    _matplotlib_import_error = e
+
+from h5config.storage.hdf5 import HDF5_Storage as _HDF5_Storage
+from h5config.storage.hdf5 import h5_create_group as _h5_create_group
+import FFT_tools as _FFT_tools
+from pypiezo.base import convert_bits_to_volts as _convert_bits_to_volts
+from pypiezo.config import ChannelConfig as _ChannelConfig
 
-    Improves on vib_analyze_naive() by first converting Vphoto(t) to 
-    frequency space, and fitting a Lorentzian in the relevant frequency
-    range (see cantilever_calib.pdf for derivation).
+from . import LOG as _LOG
+from . import package_config as _package_config
+from .config import Variance as _Variance
+from .config import BreitWigner as _BreitWigner
+from .config import OffsetBreitWigner as _OffsetBreitWigner
+from .config import VibrationConfig as _VibrationConfig
 
-    The conversion to frequency space generates an average power spectrum
-    by breaking the data into windowed chunks and averaging the power
-    spectrums for the chunks together.
-    See FFT_tools.avg_power_spectrum
+
+def vib_analyze_naive(deflection):
+    """Calculate the deflection variance in Volts**2.
+
+    This method is simple and easy to understand, but it highly
+    succeptible to noise, drift, etc.
     
-    Vphoto_in2V is a function converting Vphoto values from bits to Volts.
-    This function is assumed linear, see bump_analyze().
-    zeroVphoto_bits is the bit value corresponding to Vphoto = zero Volts.
+    Inputs
+      deflection : numpy array with deflection timeseries in Volts.
+    """
+    std = deflection.std()
+    var = std**2
+    _LOG.debug('naive deflection variance: %g V**2' % var)
+    return var
+
+def vib_analyze(deflection, vibration_config, deflection_channel_config,
+                plot=False):
+    """Calculate the deflection variance in Volts**2.
+
+    Improves on `vib_analyze_naive()` by first converting `Vphoto(t)`
+    to frequency space, and fitting a Breit-Wigner in the relevant
+    frequency range (see cantilever_calib.pdf for derivation).
+    However, there may be cases where the fit is thrown off by noise
+    spikes in frequency space.  To protect from errors, the fitted
+    variance is compared to the naive variance (where all noise is
+    included), and the minimum variance is returned.
+
+    Inputs:
+      deflection        Vphoto deflection input in bits.
+      vibration_config  `.config._VibrationConfig` instance
+      deflection_channel_config
+                        deflection `pypiezo.config.ChannelConfig` instance
+      plot              boolean overriding matplotlib config setting.
+
+    The conversion to frequency space generates an average power
+    spectrum by breaking the data into windowed chunks and averaging
+    the power spectrums for the chunks together. See
+    `FFT_tools.unitary_avg_power_spectrum()` for details.
     """
-    Vphoto_t = numpy.zeros((len(deflection_bits),),
-                           dtype=numpy.float)
     # convert the data from bits to volts
-    if config.TEXT_VERBOSE : 
-        print "Converting %d bit values to voltages" % len(Vphoto_t)
-    for i in range(len(deflection_bits)) :
-        Vphoto_t[i] = Vphoto_in2V(deflection_bits[i])
-
+    deflection_v = _convert_bits_to_volts(
+        deflection_channel_config, deflection)
+    mean = deflection_v.mean()
+    _LOG.debug('average thermal deflection (Volts): %g' % mean)
+
+    naive_variance = vib_analyze_naive(deflection_v)
+    if vibration_config['model'] == _Variance:
+        return naive_variance
+    
     # Compute the average power spectral density per unit time (in uV**2/Hz)
-    if config.TEXT_VERBOSE : 
-        print "Compute the averaged power spectral density in uV**2/Hz"
-    (freq_axis, power) = \
-        FFT_tools.unitary_avg_power_spectrum((Vphoto_t - Vphoto_t.mean())*1e6,
-                                             freq, chunk_size,overlap, window)
+    _LOG.debug('compute the averaged power spectral density in uV**2/Hz')
+    freq_axis,power = _FFT_tools.unitary_avg_power_spectrum(
+        (deflection_v - mean)*1e6, vibration_config['frequency'],
+        vibration_config['chunk-size'], vibration_config['overlap'],
+        vibration_config['window'])
 
-    A,B,C = fit_psd(freq_axis, power, minFreq, maxFreq)
+    A,B,C,D = fit_psd(
+        freq_axis, power,
+        min_frequency=vibration_config['minimum-fit-frequency'],
+        max_frequency=vibration_config['maximum-fit-frequency'],
+        offset=vibration_config['model'] == _OffsetBreitWigner)
 
-    if config.TEXT_VERBOSE : 
-        print "Fitted f(x) = C / ((A**2-x**2)**2 + (x*B)**2) with"
-        print "A = %g, \t B = %g, \t C = %g" % (A, B, C)
+    _LOG.debug('fit PSD(f) = C / ((A**2-f**2)**2 + (f*B)**2) with '
+               'A = %g, B = %g, C = %g, D = %g' % (A, B, C, D))
 
-    vib_plot(deflection_bits, freq_axis, power, A, B, C, plotVerbose=plotVerbose)
+    if plot or _package_config['matplotlib']:
+        vib_plot(deflection, freq_axis, power, A, B, C, D,
+                 vibration_config=vibration_config)
 
     # Our A is in uV**2, so convert back to Volts**2
-    return lorentzian_area(A,B,C) * 1e-12
+    fitted_variance = breit_wigner_area(A,B,C) * 1e-12
 
-def fit_psd(freq_axis, psd_data, minFreq=500, maxFreq=7000) :
-    """
-    freq_axis : array of frequencies in Hz
-    psd_data  : array of PSD amplitudes for the frequencies in freq_axis
+    _LOG.debug('fitted deflection variance: %g V**2' % fitted_variance)
 
-    Calculate the variance in the raw data due to the cantilever's 
-    thermal oscillation and convert it to Volts**2.
+    if _package_config['matplotlib']:
+        vib_plot(deflection, freq_axis, power, A, B, C, D,
+                 vibration_config=vibration_config)
 
-    Improves on vib_analyze_naive() by working on frequency space data 
-    and fitting a Lorentzian in the relevant frequency range (see
-    cantilever_calib.pdf for derivation).
+    return min(fitted_variance, naive_variance)
 
-    The conversion to frequency space generates an average power spectrum
-    by breaking the data into windowed chunks and averaging the power
-    spectrums for the chunks together.
-    See FFT_tools.unitary_avg_power_spectrum().    
+def breit_wigner(f, A, B, C, D=0):
+    """Breit-Wigner (sortof).
+
+    Inputs
+      f  Frequency
+      A  Resonant frequency
+      B  Quality Q = A/B
+      C  Scaling factor
+      D  Optional white-noise offset
+
+    All parameters must be postive.
+    """
+    return abs(C) / ((A**2-f**2)**2 + (B*f)**2) + abs(D)
+
+def fit_psd(freq_axis, psd_data, min_frequency=500, max_frequency=25000,
+            offset=False):
+    """Fit the FFTed vibration data to a Breit-Wigner.
+
+    Inputs
+      freq_axis      array of frequencies in Hz
+      psd_data       array of PSD amplitudes for the frequencies in freq_axis
+      min_frequency  lower bound of Breit-Wigner fitting region
+      max_frequency  upper bound of Breit-Wigner fitting region
+      offset         add a white-noise offset to the Breit-Wigner model
+    Output
+      Breit-Wigner model fit parameters `A`, `B`, `C`, and `D`.
     """
     # cut out the relevent frequency range
-    if config.TEXT_VERBOSE : 
-        print "Cut the frequency range %g to %g Hz" % (minFreq, maxFreq)
+    _LOG.debug('cut the frequency range %g to %g Hz'
+               % (min_frequency, max_frequency))
     imin = 0
-    while freq_axis[imin] < minFreq : imin += 1
+    while freq_axis[imin] < min_frequency : imin += 1
     imax = imin
-    while freq_axis[imax] < maxFreq : imax += 1
-    assert imax >= imin + 10 , \
-        "Less than 10 points in freq range (%g,%g)" % (minFreq, maxFreq)
-
-    # save about-to-be-fitted data to a temporary file
-    datafilename = "%s_%d" % (config.GNUFIT_DATA_BASE, time.time())
-    fd = file(datafilename, 'w')
-    for i in range(imin, imax) :
-        fd.write("%g\t%g\n" % (freq_axis[i], psd_data[i]))
-    fd.close()
-
-    # generate guesses for Lorentzian parameters A,B,C
-    max_psd_index = numpy.argmax(psd_data[imin:imax]) + imin
+    while freq_axis[imax] < max_frequency : imax += 1
+    assert imax >= imin + 10 , 'less than 10 points in freq range (%g,%g)' % (
+        min_frequency, max_frequency)
+
+    # generate guesses for Breit-Wigner parameters A, B, C, and D
+    max_psd_index = _numpy.argmax(psd_data[imin:imax]) + imin
     max_psd = psd_data[max_psd_index]
-    half_max_index = imin
-    while psd_data[half_max_index] < max_psd/2 :
-        half_max_index += 1
     res_freq = freq_axis[max_psd_index]
-    half_width = freq_axis[max_psd_index] - freq_axis[half_max_index]
-    # Lorentzian L(x) = C / ((A**2-x**2)**2 + (B*x)**2)
+
+    # Breit-Wigner L(x) = C / ((A**2-x**2)**2 + (B*x)**2)
     # is expected power spectrum for
     # x'' + B x' + A^2 x'' = F_external(t)/m
     # (A = omega_0)
     # C = (2 k_B T B) / (pi m)
     # 
     # A = resonant frequency
-    # peak at  x_res = sqrt(A^2 - B^2/2)
+    # peak at  x_res = sqrt(A^2 - B^2/2)  (by differentiating)
     #  which could be complex if there isn't a peak (overdamped)
     # peak height    = C / (3 x_res^4 + A^4)
+    #
     # Q = A/B
     #
-    # guess Q = 5, and adjust from there
-    Q_guess = 5
+    # Guessing Q = 1 is pretty safe.
+
+    Q_guess = 1
+
     # so x_res^2 = B^2 Q^2 - B^2/2 = (Q^2-1/2)B^2 
     #    B = x_res / sqrt(Q^2-1/2)
-    if config.TEXT_VERBOSE : 
-        print "params : %g, %g" % (res_freq, max_psd)
-    B_guess = res_freq / numpy.sqrt(Q_guess**2-0.5)
+    B_guess = res_freq / _numpy.sqrt(Q_guess**2-0.5)
     A_guess = Q_guess*B_guess
     C_guess = max_psd * (-res_freq**4 + A_guess**4)
-    # 
+    if offset:
+        D_guess = psd_data[max_psd_index]
+        C_guess -= D_guess
+    else:
+        D_guess = 0
+    _LOG.debug(('guessed params: resonant freq %g, max psd %g, Q %g, A %g, '
+                'B %g, C %g, D %g') % (
+            res_freq, max_psd, Q_guess, A_guess, B_guess, C_guess, D_guess))
     # Half width w on lower side when L(a-w) = L(a)/2
     #  (a**2 - (a-w)**2)**2 + (b*(a-w))**2 = 2*(b*a)**2
     # Let W=(a-w)**2, A=a**2, and B=b**2
@@ -175,260 +403,173 @@ def fit_psd(freq_axis, psd_data, minFreq=500, maxFreq=7000) :
     #  (a-w)**2 = (2A-B)/2 * [1 +/- sqrt(1 - 4A(A-2B)/(B-2A)**2]
     #  so w is a disaster ;)
     # For some values of A and B (non-underdamped), W is imaginary or negative.
-    if config.TEXT_VERBOSE : 
-        print "guesses : %g, %g, %g" % (A_guess, B_guess, C_guess)
-
-    # fit Lorentzian using Gnuplot's 'fit' command
-    g = GnuplotBiDir.Gnuplot()
-    # The Lorentzian is the predicted one-sided power spectral density
-    # For an oscillator whose position x obeys
-    # m x'' + gamma x' + kx = F_thermal(t)
-    #  A is the ?driving noise?
-    #  B is gamma, the damping term
-    #  C is omega_0, the resonant frequency
+    #
+    # The mass m is given by m = k_B T / (pi A)
+    # The spring constant k is given by k = m (omega_0)**2
+    # The quality factor Q is given by Q = omega_0 m / gamma
+
     # Fitting the PSD of V = photoSensitivity*x just rescales the parameters
-    g("f(x) = C / ((A**2-x**2)**2 + (B*x)**2)")
-    ## The mass m is given by m = k_B T / (pi A)
-    ## The spring constant k is given by k = m (omega_0)**2
-    ## The quality factor Q is given by Q = omega_0 m / gamma
-    g("A=%g; B=%g; C=%g" % (A_guess, B_guess, C_guess))
-    g("fit f(x) '%s' via A,B,C" % datafilename)
-    A=abs(float(g.getvar('A'))) # A and B only show up as squares in f(x)
-    B=abs(float(g.getvar('B'))) # so ensure we get positive values
-    C=float(g.getvar('C'))
-    os.remove(datafilename)
-    return (A, B, C)
-
-def lorentzian_area(A, B, C) :
+
+    # fit Breit-Wigner using scipy.optimize.leastsq
+    def residual(p, y, x):
+        return breit_wigner(x, *p) - y
+    if offset:
+        guess = _numpy.array((A_guess, B_guess, C_guess, D_guess))
+    else:
+        guess = _numpy.array((A_guess, B_guess, C_guess))
+
+    p,cov,info,mesg,ier = _leastsq(
+        residual, guess,
+        args=(psd_data[imin:imax], freq_axis[imin:imax]),
+        full_output=True, maxfev=10000)
+    _LOG.debug('fitted params: %s' % p)
+    _LOG.debug('covariance matrix: %s' % cov)
+    #_LOG.debug('info: %s' % info)
+    _LOG.debug('message: %s' % mesg)
+    if ier == 1:
+        _LOG.debug('solution converged')
+    else:
+        _LOG.debug('solution did not converge')
+    if offset:
+        A,B,C,D = p
+    else:
+        A,B,C = p
+        D = 0
+    A=abs(A) # A and B only show up as squares in f(x)
+    B=abs(B) # so ensure we get positive values.
+    C=abs(C) # Only abs(C) is used in breit_wigner().
+    return (A, B, C, D)
+
+def breit_wigner_area(A, B, C):
     # Integrating the the power spectral density per unit time (power) over the
     # frequency axis [0, fN] returns the total signal power per unit time
     #  int_0^fN power(f)df = 1/T int_0^T |x(t)**2| dt
     #                      = <V(t)**2>, the variance for our AC signal.
-    # The variance from our fitted Lorentzian is the area under the Lorentzian
+    # The variance from our fitted Breit-Wigner is the area under the Breit-Wigner
     #  <V(t)**2> = (pi*C) / (2*B*A**2)
-    return (numpy.pi * C) / (2 * B * A**2)
+    return (_numpy.pi * C) / (2 * B * A**2)
+
+def vib_save(filename, group='/', raw_vibration=None, vibration_config=None,
+             deflection_channel_config=None, processed_vibration=None):
+    with _h5py.File(filename, 'a') as f:
+        cwg = _h5_create_group(f, group)
+        if raw_vibration is not None:
+            try:
+                del cwg['raw/deflection']
+            except KeyError:
+                pass
+            cwg['raw/deflection'] = raw_vibration
+        storage = _HDF5_Storage()
+        for config,key in [(vibration_config, 'config/vibration'),
+                           (deflection_channel_config,
+                            'config/deflection')]:
+            if config is None:
+                continue
+            config_cwg = _h5_create_group(cwg, key)
+            storage.save(config=config, group=config_cwg)
+        if processed_vibration is not None:
+            try:
+                del cwg['processed']
+            except KeyError:
+                pass
+            cwg['processed'] = processed_vibration
+
+def vib_load(filename, group='/'):
+    assert group.endswith('/')
+    raw_vibration = processed_vibration = None
+    configs = []
+    with _h5py.File(filename, 'a') as f:
+        try:
+            raw_vibration = f[group+'raw/deflection'][...]
+        except KeyError:
+            pass
+        for Config,key in [(_VibrationConfig, 'config/vibration'),
+                           (_ChannelConfig, 'config/deflection')]:
+            config = Config(storage=_HDF5_Storage(
+                    filename=filename, group=group+key))
+            configs.append(config)
+        try:
+            processed_vibration = float(f[group+'processed'][...])
+        except KeyError:
+            pass
+    ret = [raw_vibration]
+    ret.extend(configs)
+    ret.append(processed_vibration)
+    for config in configs:
+        config.load()
+    return tuple(ret)
+
+def vib_plot(deflection=None, freq_axis=None, power=None, A=None, B=None,
+             C=None, D=0, vibration_config=None, analyze=False):
+    """Plot 3 subfigures displaying vibration data and analysis.
 
-def gnuplot_check_fit(datafilename, A, B, C) :
-    """
-    return a string containing a gnuplot script displaying the fit.
-    """
-    string = 'f(x) = C / ((A**2-x**2)**2 + (x*B)**2)\n'
-    string += 'A= %g ; B= %g ; C= %g\n' % (A, B, C)
-    string += 'set logscale y\n'
-    string += "plot '%s', f(x)\n" % datafilename
-    return string
-
-def vib_save(data, log_dir=None) :
-    """Save the dictionary data, using data_logger.data_log()
-    data should be a dictionary of arrays with the fields
-      'Z piezo output'
-      'Z piezo input'
-      'Deflection input'
-    """
-    if log_dir :
-        log = data_logger.data_log(log_dir, noclobber_logsubdir=False,
-                                   log_name="vib_%gHz" % freq)
-        log.write_dict_of_arrays(data)
-
-def vib_load(datafile=None) :
-    """Load the dictionary data, using data_logger.date_load()"
-    data should be a dictionary of arrays with the fields
-      'Z piezo output'
-      'Z piezo input'
-      'Deflection input'
-    """
-    dl = data_logger.data_load()
-    data = dl.read_dict_of_arrays(datafile)
-    return data
-
-def vib_plot(deflection_bits, freq_axis, power, A, B, C,
-             plotVerbose=True) :
-    """
-    If plotVerbose or config.PYLAB_VERBOSE == True, plot 3 subfigures:
      Time series (Vphoto vs sample index) (show any major drift events),
      A histogram of Vphoto, with a gaussian fit (demonstrate randomness), and
      FFTed Vphoto data (Vphoto vs Frequency) (show major noise components).
     """
-    if plotVerbose or config.PYLAB_VERBOSE :
-        print "plotting"
-        common._import_pylab()
-        common._pylab.figure(config.BASE_FIGNUM+2)
-        common._pylab.hold(False)
+    if not _matplotlib:
+        raise _matplotlib_import_error
+    figure = _matplotlib_pyplot.figure()
+
+    if power is None:
+        assert deflection != None, (
+            'must set at least one of `deflection` or `power`.')
+        time_axes = figure.add_subplot(2, 1, 1)
+        hist_axes = figure.add_subplot(2, 1, 2)
+        freq_axes = None
+    elif deflection is None:
+        time_axes = hist_axes = None
+        freq_axes = figure.add_subplot(1, 1, 1)
+    else:
+        time_axes = figure.add_subplot(3, 1, 1)
+        hist_axes = figure.add_subplot(3, 1, 2)
+        freq_axes = figure.add_subplot(3, 1, 3)
+        
+    timestamp = _time.strftime('%H%M%S')
 
-        # plot time series
-        common._pylab.subplot(311)
-        common._pylab.plot(deflection_bits, 'r.')
-        common._pylab.title("free oscillation")
+    if deflection is not None:
+        time_axes.plot(deflection, 'r.')
+        time_axes.set_title('free oscillation')
 
         # plot histogram distribution and gaussian fit
-        common._pylab.subplot(312)
-        n, bins, patches = \
-            common._pylab.hist(deflection_bits, bins=30,
-                               normed=1, align='center')
-        gaus = numpy.zeros((len(bins),))
-        mean = deflection_bits.mean()
-        std = deflection_bits.std()
-        pi = numpy.pi
-        exp = numpy.exp
-        for i in range(len(bins)) :
-            gaus[i] = (2*pi)**(-.5)/std * exp(-0.5*((bins[i]-mean)/std)**2)
-        common._pylab.hold(True)
-        common._pylab.plot(bins, gaus, 'r-');
-        common._pylab.hold(False)
-
-        # plot FFTed data
-        common._pylab.subplot(313)
-        common._pylab.semilogy(freq_axis, power, 'r.-')
-        common._flush_plot()
-    if (plotVerbose or config.GNUPLOT_VERBOSE):
-        # write all the ft data now
-        fd = file(datafilename, 'w')
-        for i in range(len(freq_axis)) :
-            fd.write("%g\t%g\n" % (freq_axis[i], power[i]))
-        fd.write("\n") # blank line to separate fit data.
-        # write the fit data
-        for i in range(imin, imax) :
-            x = freq_axis[i]
-            fd.write("%g\t%g\n" % (freq_axis[i],
-                                   C / ((A**2-x**2)**2 + (x*B)**2) ) )
-        fd.close()
-        print Vphoto_t.var(), A, B, C, ((numpy.pi * C)/(2 * B * A**2) * 1e-12)
-        g("set terminal epslatex color solid")
-        g("set output 'calibration.tex'")
-        g("set size 2,2") # multipliers on default 5"x3.5"
-        #g("set title 'Thermal calibration'")
-        g("set logscale y")
-        g("set xlabel 'Frequency (Hz)'")
-        g("set ylabel 'Power ($\mu$V$^2$/Hz)'")
-        g("set xrange [0:20000]")
-        g("g(x) = x < %g || x > %g ? 1/0 : f(x)" % (minFreq, maxFreq)) # only plot on fitted region
-        g("plot '%s' title 'Vibration data', g(x) title 'Lorentzian fit'" % datafilename)
-
-        g("set mouse")
-        g("pause mouse 'click with mouse'")
-        g.getvar("MOUSE_BUTTON")
-        del(g)
-
-def vib_load_analyze_tweaked(tweak_file, minFreq=1000, maxFreq=7000,
-                             chunk_size=2048, overlap=True,
-                             window=FFT_tools.window_hann,
-                             Vphoto_in2V=config.Vphoto_in2V,
-                             plotVerbose=False) :
-    """
-    Read the vib files listed in tweak_file.
-    Return an array of Vphoto variances (due to the cantilever) in Volts**2
-    """
-    dl = data_logger.data_load()
-    Vphoto_var = []
-    for path in file(tweak_file, 'r') :
-        if path[-1] == '\n':
-            path = path.split('\n')[0]
-        # read the data
-        data = vib_load(path)
-        deflection_bits = data['Deflection input']
-        freq = float(path.split('_')[-1].split('Hz')[0])
-        if config.TEXT_VERBOSE :
-            print "Analyzing '%s' at %g Hz" % (path, freq)
-        # analyze
-        Vphoto_var.append(vib_analyze(deflection_bits, freq, minFreq, maxFreq,
-                                      chunk_size, overlap, window,
-                                      Vphoto_in2V, plotVerbose))
-    return numpy.array(Vphoto_var, dtype=numpy.float)
-
-# commandline interface functions
-import scipy.io, sys
-
-def read_data(ifile):
-    """
-    ifile can be a filename string or open (seekable) file object.
-    returns an array of data values (1 column)
-    """
-    #returns (column 1 array, column 2 array)
-    #"""
-    if ifile == None :  ifile = sys.stdin
-    unlabeled_data=scipy.io.read_array(ifile)
-    return unlabeled_data #(unlabeled_data[:,0], unlabeled_data[:,1])
-
-if __name__ == '__main__' :
-    # command line interface
-    from optparse import OptionParser
-    
-    usage_string = ('%prog <input-file>\n'
-                    '2008, W. Trevor King.\n'
-                    '\n'
-                    'Deflection power spectral density (PSD) data (X^2/Hz)\n'
-                    'and returns the variance in X units (X^2)'
-                    '<input-file> should be whitespace-delimited, 2 column ASCII\n'
-                    'without a header line.\n'
-                    'e.g: "<frequency_Hz>\\t<deflection_psd X^2/Hz>\\n"\n')
-    parser = OptionParser(usage=usage_string, version='%prog 0.1')
-    parser.add_option('-f', '--sample-freq', dest='freq', default=100e3,
-                      help='sample frequency in Hz (default %default)',
-                      type='float', metavar='FREQ')
-    parser.add_option('-m', '--min-freq', dest='min_freq', default=500,
-                      help='minimum frequency in Hz (default %default)',
-                      type='float', metavar='FREQ')
-    parser.add_option('-M', '--max-freq', dest='max_freq', default=7000,
-                      help='maximum frequency in Hz (default %default)',
-                      type='float', metavar='FREQ')
-    parser.add_option('-o', '--output-file', dest='ofilename',
-                      help='write output to FILE (default stdout)',
-                      type='string', metavar='FILE')
-    parser.add_option('-c', '--comma-out', dest='comma_out', action='store_true',
-                      help='Output comma-seperated values (default %default)',
-                      default=False)
-    parser.add_option('-g', '--gnuplot', dest='gnuplot', action='store_true',
-                      help='Print gnuplot fit check script to stderr',
-                      default=False)
-    parser.add_option('-p', '--pylab', dest='pylab', action='store_true',
-                      help='Produce pylab fit checks during execution',
-                      default=False)
-    parser.add_option('-t', '--tweak-mode', dest='tweakmode', action='store_true',
-                      help='Run in tweak-file mode',
-                      default=False)
-    parser.add_option('-v', '--verbose', dest='verbose', action='store_true',
-                      help='Print lots of debugging information',
-                      default=False)
-
-    options,args = parser.parse_args()
-    parser.destroy()
-    assert len(args) >= 1, "Need an input file"
-        
-    ifilename = args[0]
-
-    if options.ofilename != None :
-        ofile = file(options.ofilename, 'w')
-    else :
-        ofile = sys.stdout
-    if options.verbose == True :
-        vfile = sys.stderr
-    else :
-        vfile = None
-    config.TEXT_VERBOSE = options.verbose
-    config.PYLAB_INTERACTIVE = False
-    config.PYLAB_VERBOSE = options.pylab
-    config.GNUPLOT_VERBOSE = options.gnuplot
-
-    if options.tweakmode == False :
-        data = read_data(ifilename)
-        deflection_bits = data['Deflection input']
-        Vphoto_var = vib_analyze(deflection_bits, freq=options.freq,
-                                 minFreq=options.min_freq,
-                                 maxFreq=options.max_freq)
-        print >> ofile, Vphoto_var
-    else : # tweak mode
-        Vphoto_vars = vib_load_analyze_tweaked(ifilename,
-                                               minFreq=options.min_freq,
-                                               maxFreq=options.max_freq)
-        if options.comma_out :
-            sep = ','
-        else :
-            sep = '\n'
-        common.write_array(ofile, Vphoto_vars, sep)
-
-    if common._final_flush_plot != None:
-        common._final_flush_plot()
-    
-    if options.ofilename != None :
-        ofile.close()
+        hist_axes.hold(True)
+        n,bins,patches = hist_axes.hist(
+            deflection, bins=30, normed=True, align='mid')
+        gauss = _numpy.zeros((len(bins),), dtype=_numpy.float)
+        mean = deflection.mean()
+        std = deflection.std()
+        pi = _numpy.pi
+        exp = _numpy.exp
+        gauss = _numpy.sqrt(2*pi)/std * exp(-0.5*((bins-mean)/std)**2)
+        # Matplotlib's normed histogram uses bin heights of n/(len(x)*dbin)
+        dbin = bins[1]-bins[0]
+        hist_axes.plot(bins, gauss/dbin, 'r-')
+    if power is not None:
+        freq_axes.hold(True)
+        freq_axes.set_yscale('log')
+        freq_axes.plot(freq_axis, power, 'r.-')
+        xmin,xmax = freq_axes.get_xbound()
+        ymin,ymax = freq_axes.get_ybound()
     
+        # highlight the region we're fitting
+        freq_axes.axvspan(
+            vibration_config['minimum-fit-frequency'],
+            vibration_config['maximum-fit-frequency'],
+            facecolor='g', alpha=0.1, zorder=-2)
+
+        if A is not None:
+            fitdata = breit_wigner(freq_axis, A, B, C, D)
+            freq_axes.plot(freq_axis, fitdata, 'b-')
+            noisefloor = D + 0*freq_axis;
+            freq_axes.plot(freq_axis, noisefloor)
+
+            if B**2 < 2*A**2:
+                res_freq = _numpy.sqrt(A**2 - B**2/2)
+                freq_axes.axvline(res_freq, color='b', zorder=-1)
+
+        freq_axes.set_title('power spectral density %s' % timestamp)
+        freq_axes.axis([xmin,xmax,ymin,ymax])
+        freq_axes.set_xlabel('frequency (Hz)')
+        freq_axes.set_ylabel('PSD')
+
+    figure.show()