3 # calibcant - tools for thermally calibrating AFM cantilevers
5 # Copyright (C) 2007,2008, William Trevor King
7 # This program is free software; you can redistribute it and/or
8 # modify it under the terms of the GNU General Public License as
9 # published by the Free Software Foundation; either version 3 of the
10 # License, or (at your option) any later version.
12 # This program is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
15 # See the GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with this program; if not, write to the Free Software
19 # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA
22 # The author may be contacted at <wking@drexel.edu> on the Internet, or
23 # write to Trevor King, Drexel University, Physics Dept., 3141 Chestnut St.,
24 # Philadelphia PA 19104, USA.
27 Separate the more general vib_analyze() from the other vib_*()
28 functions in calibcant. Also provide a command line interface
29 for analyzing data acquired through other workflows.
31 The relevent physical quantities are :
32 Vphoto The photodiode vertical deflection voltage (what we measure)
35 import os, time, numpy
36 import GnuplotBiDir # used for fitting lorentzian
37 import scipy.optimize # alternative for fitting lorentzian
38 import common # common module for the calibcant package
39 import config # config module for the calibcant package
43 PLOT_GUESSED_LORENTZIAN=False
45 def vib_analyze_naive(deflection_bits, Vphoto_in2V=config.Vphoto_in2V,
46 zeroVphoto_bits=config.zeroVphoto_bits) :
48 Calculate the variance of the raw data, and convert it to Volts**2.
49 This method is simple and easy to understand, but it highly succeptible
52 Vphoto_in2V is a function converting Vphoto values from bits to Volts.
53 This function is assumed linear, see bump_analyze().
55 Vphoto_std_bits = deflection_bits.std()
56 Vphoto_std = Vphoto_in2V(Vphoto_std_bits + zeroVphoto_bits)
59 def vib_analyze(deflection_bits, freq, minFreq=500, maxFreq=7000,
60 chunk_size=4096, overlap=True, window=FFT_tools.window_hann,
61 Vphoto_in2V=config.Vphoto_in2V, plotVerbose=False) :
63 Calculate the variance in the raw data due to the cantilever's
64 thermal oscillation and convert it to Volts**2.
66 Improves on vib_analyze_naive() by first converting Vphoto(t) to
67 frequency space, and fitting a Lorentzian in the relevant
68 frequency range (see cantilever_calib.pdf for derivation).
69 However, there may be cases where the fit is thrown off by noise
70 spikes in frequency space. To protect from errors, the fitted
71 variance is compared to the naive variance (where all noise is
72 included), and the minimum variance is returned.
74 Vphoto_in2V is a function converting Vphoto values from bits to Volts.
75 This function is assumed linear, see bump_analyze().
77 Vphoto_t = numpy.zeros((len(deflection_bits),),
79 # convert the data from bits to volts
80 if config.TEXT_VERBOSE :
81 print "Converting %d bit values to voltages" % len(Vphoto_t)
82 for i in range(len(deflection_bits)) :
83 Vphoto_t[i] = Vphoto_in2V(deflection_bits[i])
85 # Compute the average power spectral density per unit time (in uV**2/Hz)
86 if config.TEXT_VERBOSE :
87 print "Compute the averaged power spectral density in uV**2/Hz"
88 (freq_axis, power) = \
89 FFT_tools.unitary_avg_power_spectrum((Vphoto_t - Vphoto_t.mean())*1e6,
90 freq, chunk_size,overlap, window)
92 A,B,C = fit_psd(freq_axis, power, minFreq, maxFreq)
94 if config.TEXT_VERBOSE :
95 print "Fitted f(x) = C / ((A**2-x**2)**2 + (x*B)**2) with"
96 print "A = %g, \t B = %g, \t C = %g" % (A, B, C)
98 vib_plot(deflection_bits, freq_axis, power, A, B, C,
99 minFreq, maxFreq, plotVerbose=plotVerbose)
101 # Our A is in uV**2, so convert back to Volts**2
102 fitted_variance = lorentzian_area(A,B,C) * 1e-12
104 naive_variance = vib_analyze_naive(deflection_bits,
105 Vphoto_in2V=Vphoto_in2V)
106 if config.TEXT_VERBOSE:
107 print "Fitted variance: %g V**2", fitted_variance
108 print "Naive variance : %g V**2", naive_variance
110 return min(fitted_variance, naive_variance)
112 def fit_psd(freq_axis, psd_data, minFreq=500, maxFreq=7000) :
114 freq_axis : array of frequencies in Hz
115 psd_data : array of PSD amplitudes for the frequencies in freq_axis
117 Calculate the variance in the raw data due to the cantilever's
118 thermal oscillation and convert it to Volts**2.
120 The conversion to frequency space generates an average power spectrum
121 by breaking the data into windowed chunks and averaging the power
122 spectrums for the chunks together.
123 See FFT_tools.unitary_avg_power_spectrum().
125 # cut out the relevent frequency range
126 if config.TEXT_VERBOSE :
127 print "Cut the frequency range %g to %g Hz" % (minFreq, maxFreq)
129 while freq_axis[imin] < minFreq : imin += 1
131 while freq_axis[imax] < maxFreq : imax += 1
132 assert imax >= imin + 10 , \
133 "Less than 10 points in freq range (%g,%g)" % (minFreq, maxFreq)
135 # generate guesses for Lorentzian parameters A,B,C
136 max_psd_index = numpy.argmax(psd_data[imin:imax]) + imin
137 max_psd = psd_data[max_psd_index]
138 half_max_index = imin
139 while psd_data[half_max_index] < max_psd/2 :
141 res_freq = freq_axis[max_psd_index]
142 half_width = freq_axis[max_psd_index] - freq_axis[half_max_index]
143 # Lorentzian L(x) = C / ((A**2-x**2)**2 + (B*x)**2)
144 # is expected power spectrum for
145 # x'' + B x' + A^2 x'' = F_external(t)/m
147 # C = (2 k_B T B) / (pi m)
149 # A = resonant frequency
150 # peak at x_res = sqrt(A^2 - B^2/2)
151 # which could be complex if there isn't a peak (overdamped)
152 # peak height = C / (3 x_res^4 + A^4)
155 # guess Q = 1, and adjust from there
157 # so x_res^2 = B^2 Q^2 - B^2/2 = (Q^2-1/2)B^2
158 # B = x_res / sqrt(Q^2-1/2)
159 if config.TEXT_VERBOSE :
160 print "params : %g, %g" % (res_freq, max_psd)
161 B_guess = res_freq / numpy.sqrt(Q_guess**2-0.5)
162 A_guess = Q_guess*B_guess
163 C_guess = max_psd * (-res_freq**4 + A_guess**4)
165 # Half width w on lower side when L(a-w) = L(a)/2
166 # (a**2 - (a-w)**2)**2 + (b*(a-w))**2 = 2*(b*a)**2
167 # Let W=(a-w)**2, A=a**2, and B=b**2
168 # (A - W)**2 + BW = 2*AB
169 # W**2 - 2AW + A**2 + BW = 2AB
170 # W**2 + (B-2A)W + (A**2-2AB) = 0
171 # W = (2A-B)/2 * [1 +/- sqrt(1 - 4(A**2-2AB)/(B-2A)**2]
172 # = (2A-B)/2 * [1 +/- sqrt(1 - 4A(A-2B)/(B-2A)**2]
173 # (a-w)**2 = (2A-B)/2 * [1 +/- sqrt(1 - 4A(A-2B)/(B-2A)**2]
174 # so w is a disaster ;)
175 # For some values of A and B (non-underdamped), W is imaginary or negative.
177 # The mass m is given by m = k_B T / (pi A)
178 # The spring constant k is given by k = m (omega_0)**2
179 # The quality factor Q is given by Q = omega_0 m / gamma
180 if config.TEXT_VERBOSE :
181 print "guesses : %g, %g, %g" % (A_guess, B_guess, C_guess)
182 if PLOT_GUESSED_LORENTZIAN:
183 vib_plot(None, freq_axis, psd_data, A_guess, B_guess, C_guess,
184 minFreq, maxFreq, plotVerbose=True)
186 # Fitting the PSD of V = photoSensitivity*x just rescales the parameters
188 if False: # Gnuplot fit worse than scipy.optimize.leastsq fit.
189 # fit Lorentzian using Gnuplot's 'fit' command
191 # save about-to-be-fitted data to a temporary file
192 datafilename = "%s_%d" % (config.GNUFIT_DATA_BASE, time.time())
193 fd = file(datafilename, 'w')
194 for i in range(imin, imax) :
195 fd.write("%g\t%g\n" % (freq_axis[i], psd_data[i]))
198 g = GnuplotBiDir.Gnuplot()
199 g("f(x) = C / ((A**2-x**2)**2 + (B*x)**2)")
200 g("A=%g; B=%g; C=%g" % (A_guess, B_guess, C_guess))
201 g("fit f(x) '%s' via A,B,C" % datafilename)
202 A=abs(float(g.getvar('A'))) # A and B only show up as squares in f(x)
203 B=abs(float(g.getvar('B'))) # so ensure we get positive values
204 C=float(g.getvar('C'))
206 os.remove(datafilename)
208 # fit Lorenzian using scipy.optimize.leastsq
209 def residual(p, y, x):
213 Y = C / ((A**2-x**2)**2 + (B*x)**2)
215 leastsq = scipy.optimize.leastsq
216 p,cov,info,mesg,ier = leastsq(residual, (A_guess, B_guess, C_guess),
217 args=(psd_data[imin:imax],
218 freq_axis[imin:imax]),
219 full_output=True, maxfev=10000)
220 if config.TEXT_VERBOSE :
221 print "Fitted params:",p
222 print "Covariance mx:",cov
226 print "Solution converged"
228 print "Solution did not converge"
230 A=abs(A) # A and B only show up as squares in f(x)
231 B=abs(B) # so ensure we get positive values
234 def lorentzian_area(A, B, C) :
235 # Integrating the the power spectral density per unit time (power) over the
236 # frequency axis [0, fN] returns the total signal power per unit time
237 # int_0^fN power(f)df = 1/T int_0^T |x(t)**2| dt
238 # = <V(t)**2>, the variance for our AC signal.
239 # The variance from our fitted Lorentzian is the area under the Lorentzian
240 # <V(t)**2> = (pi*C) / (2*B*A**2)
241 return (numpy.pi * C) / (2 * B * A**2)
243 def gnuplot_check_fit(datafilename, A, B, C) :
245 return a string containing a gnuplot script displaying the fit.
247 string = 'f(x) = C / ((A**2-x**2)**2 + (x*B)**2)\n'
248 string += 'A= %g ; B= %g ; C= %g\n' % (A, B, C)
249 string += 'set logscale y\n'
250 string += "plot '%s', f(x)\n" % datafilename
253 def vib_save(data, log_dir=None) :
254 """Save the dictionary data, using data_logger.data_log()
255 data should be a dictionary of arrays with the fields
261 log = data_logger.data_log(log_dir, noclobber_logsubdir=False,
262 log_name="vib_%gHz" % freq)
263 log.write_dict_of_arrays(data)
265 def vib_load(datafile=None) :
266 """Load the dictionary data, using data_logger.date_load()"
267 data should be a dictionary of arrays with the fields
272 dl = data_logger.data_load()
273 data = dl.read_dict_of_arrays(datafile)
276 def vib_plot(deflection_bits, freq_axis, power, A, B, C,
277 minFreq=None, maxFreq=None, plotVerbose=True) :
279 If plotVerbose or config.PYLAB_VERBOSE == True, plot 3 subfigures:
280 Time series (Vphoto vs sample index) (show any major drift events),
281 A histogram of Vphoto, with a gaussian fit (demonstrate randomness), and
282 FFTed Vphoto data (Vphoto vs Frequency) (show major noise components).
284 if plotVerbose or config.PYLAB_VERBOSE :
285 common._import_pylab()
286 common._pylab.figure(config.BASE_FIGNUM+2)
288 if deflection_bits != None:
290 common._pylab.subplot(311)
291 common._pylab.hold(False)
292 common._pylab.plot(deflection_bits, 'r.')
293 common._pylab.title("free oscillation")
295 # plot histogram distribution and gaussian fit
296 common._pylab.subplot(312)
297 common._pylab.hold(False)
299 common._pylab.hist(deflection_bits, bins=30,
300 normed=1, align='center')
301 gaus = numpy.zeros((len(bins),), dtype=numpy.float)
302 mean = deflection_bits.mean()
303 std = deflection_bits.std()
306 for i in range(len(bins)) :
307 gaus[i] = (2*pi)**(-.5)/std * exp(-0.5*((bins[i]-mean)/std)**2)
308 common._pylab.hold(True)
309 common._pylab.plot(bins, gaus, 'r-');
310 common._pylab.hold(False)
313 axes = common._pylab.subplot(313)
315 # use a nice big subplot just for the FFTed data
316 axes = common._pylab.subplot(111)
317 common._pylab.hold(False)
318 common._pylab.semilogy(freq_axis, power, 'r.-')
319 common._pylab.hold(True)
320 xmin,xmax = axes.get_xbound()
321 ymin,ymax = axes.get_ybound()
323 if minFreq is not None and maxFreq is not None:
324 # highlight the region we're fitting in
325 common._pylab.axvspan(minFreq, maxFreq, facecolor='g', alpha=0.1,
328 fitdata = C / ((A**2-freq_axis**2)**2 + (B*freq_axis)**2)
329 common._pylab.plot(freq_axis, fitdata, 'b-');
330 common._pylab.hold(False)
331 axes.axis([xmin,xmax,ymin,ymax])
334 if (plotVerbose or config.GNUPLOT_VERBOSE) and False: # TODO: cleanup and test
335 # write all the ft data now
336 fd = file(datafilename, 'w')
337 for i in range(len(freq_axis)) :
338 fd.write("%g\t%g\n" % (freq_axis[i], power[i]))
339 fd.write("\n") # blank line to separate fit data.
341 for i in range(imin, imax) :
343 fd.write("%g\t%g\n" % (freq_axis[i],
344 C / ((A**2-x**2)**2 + (x*B)**2) ) )
346 print Vphoto_t.var(), A, B, C, ((numpy.pi * C)/(2 * B * A**2) * 1e-12)
347 g("set terminal epslatex color solid")
348 g("set output 'calibration.tex'")
349 g("set size 2,2") # multipliers on default 5"x3.5"
350 #g("set title 'Thermal calibration'")
352 g("set xlabel 'Frequency (Hz)'")
353 g("set ylabel 'Power ($\mu$V$^2$/Hz)'")
354 g("set xrange [0:20000]")
355 g("g(x) = x < %g || x > %g ? 1/0 : f(x)" % (minFreq, maxFreq)) # only plot on fitted region
356 g("plot '%s' title 'Vibration data', g(x) title 'Lorentzian fit'" % datafilename)
359 g("pause mouse 'click with mouse'")
360 g.getvar("MOUSE_BUTTON")
363 def vib_load_analyze_tweaked(tweak_file, minFreq=1000, maxFreq=7000,
364 chunk_size=2048, overlap=True,
365 window=FFT_tools.window_hann,
366 Vphoto_in2V=config.Vphoto_in2V,
369 Read the vib files listed in tweak_file.
370 Return an array of Vphoto variances (due to the cantilever) in Volts**2
372 dl = data_logger.data_load()
374 for path in file(tweak_file, 'r') :
376 path = path.split('\n')[0]
378 data = vib_load(path)
379 deflection_bits = data['Deflection input']
380 freq = float(path.split('_')[-1].split('Hz')[0])
381 if config.TEXT_VERBOSE :
382 print "Analyzing '%s' at %g Hz" % (path, freq)
384 Vphoto_var.append(vib_analyze(deflection_bits, freq, minFreq, maxFreq,
385 chunk_size, overlap, window,
386 Vphoto_in2V, plotVerbose))
387 return numpy.array(Vphoto_var, dtype=numpy.float)
389 # commandline interface functions
392 def read_data(ifile):
394 ifile can be a filename string or open (seekable) file object.
395 returns an array of data values (1 column)
397 #returns (column 1 array, column 2 array)
399 if ifile == None : ifile = sys.stdin
400 unlabeled_data=scipy.io.read_array(ifile)
401 return unlabeled_data #(unlabeled_data[:,0], unlabeled_data[:,1])
403 if __name__ == '__main__' :
404 # command line interface
405 from optparse import OptionParser
407 usage_string = ('%prog <input-file>\n'
408 '2008, W. Trevor King.\n'
410 'Deflection power spectral density (PSD) data (X^2/Hz)\n'
411 'and returns the variance in X units (X^2)\n'
412 '<input-file> should be 1 column ASCII without a header line.\n'
413 'e.g: "<deflection bits>\\n..."\n')
414 parser = OptionParser(usage=usage_string, version='%prog 0.1')
415 parser.add_option('-f', '--sample-freq', dest='freq', default=100e3,
416 help='sample frequency in Hz (default %default)',
417 type='float', metavar='FREQ')
418 parser.add_option('-m', '--min-freq', dest='min_freq', default=500,
419 help='minimum frequency in Hz (default %default)',
420 type='float', metavar='FREQ')
421 parser.add_option('-M', '--max-freq', dest='max_freq', default=7000,
422 help='maximum frequency in Hz (default %default)',
423 type='float', metavar='FREQ')
424 parser.add_option('-o', '--output-file', dest='ofilename',
425 help='write output to FILE (default stdout)',
426 type='string', metavar='FILE')
427 parser.add_option('-c', '--comma-out', dest='comma_out', action='store_true',
428 help='Output comma-seperated values (default %default)',
430 parser.add_option('-g', '--gnuplot', dest='gnuplot', action='store_true',
431 help='Print gnuplot fit check script to stderr',
433 parser.add_option('-p', '--pylab', dest='pylab', action='store_true',
434 help='Produce pylab fit checks during execution',
436 parser.add_option('-G', '--plot-guess', dest='plot_guess', action='store_true',
437 help='Produce plots of the pre-fit Lorentzian',
439 parser.add_option('-t', '--tweak-mode', dest='tweakmode', action='store_true',
440 help='Run in tweak-file mode',
442 parser.add_option('-d', '--datalogger-mode', dest='datalogger_mode', action='store_true',
443 help='Read input files with datalogger.read_dict_of_arrays(). This is useful, for example, to test a single line from a tweakfile.',
445 parser.add_option('-v', '--verbose', dest='verbose', action='store_true',
446 help='Print lots of debugging information',
449 options,args = parser.parse_args()
451 assert len(args) >= 1, "Need an input file"
455 if options.ofilename != None :
456 ofile = file(options.ofilename, 'w')
459 if options.verbose == True :
463 config.TEXT_VERBOSE = options.verbose
464 config.PYLAB_INTERACTIVE = False
465 config.PYLAB_VERBOSE = options.pylab
466 config.GNUPLOT_VERBOSE = options.gnuplot
467 PLOT_GUESSED_LORENTZIAN = options.plot_guess
469 if options.tweakmode == False :
470 if options.datalogger_mode:
471 data = vib_load(ifilename)
472 deflection_bits = data['Deflection input']
474 deflection_bits = read_data(ifilename)
475 Vphoto_var = vib_analyze(deflection_bits, freq=options.freq,
476 minFreq=options.min_freq,
477 maxFreq=options.max_freq)
478 print >> ofile, Vphoto_var
480 Vphoto_vars = vib_load_analyze_tweaked(ifilename,
481 minFreq=options.min_freq,
482 maxFreq=options.max_freq)
483 if options.comma_out :
487 common.write_array(ofile, Vphoto_vars, sep)
489 if common._final_flush_plot != None:
490 common._final_flush_plot()
492 if options.ofilename != None :