1 # Copyright (C) 2008-2012 Alberto Gomez-Casado <a.gomezcasado@tnw.utwente.nl>
2 # Marco Brucale <marco.brucale@unibo.it>
3 # Massimo Sandal <devicerandom@gmail.com>
4 # W. Trevor King <wking@drexel.edu>
6 # This file is part of Hooke.
8 # Hooke is free software: you can redistribute it and/or modify it
9 # under the terms of the GNU Lesser General Public License as
10 # published by the Free Software Foundation, either version 3 of the
11 # License, or (at your option) any later version.
13 # Hooke is distributed in the hope that it will be useful, but WITHOUT
14 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
15 # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General
16 # Public License for more details.
18 # You should have received a copy of the GNU Lesser General Public
19 # License along with Hooke. If not, see
20 # <http://www.gnu.org/licenses/>.
22 """The ``vclamp`` module provides :class:`VelocityClampPlugin` and
23 several associated :class:`hooke.command.Command`\s for handling
24 common velocity clamp analysis tasks.
33 from ..command import Argument, Failure, NullQueue
34 from ..config import Setting
35 from ..curve import Data
36 from ..util.fit import PoorFit, ModelFitter
37 from ..util.si import join_data_label, split_data_label
39 from .curve import ColumnAddingCommand
42 class SurfacePositionModel (ModelFitter):
43 """Bilinear surface position model.
45 The bilinear model is symmetric, but the parameter guessing and
46 sanity checks assume the contact region occurs for lower indicies
47 ("left of") the non-contact region. We also assume that
48 tip-surface attractions produce positive deflections.
52 Algorithm borrowed from WTK's `piezo package`_, specifically
53 from :func:`piezo.z_piezo_utils.analyzeSurfPosData`.
56 http://www.physics.drexel.edu/~wking/code/git/git.php?p=piezo.git
58 Fits the data to the bilinear :method:`model`.
60 In order for this model to produce a satisfactory fit, there
61 should be enough data in the off-surface region that interactions
62 due to proteins, etc. will not seriously skew the fit in the
63 off-surface region. If you don't have much of a tail, you can set
64 the `info` dict's `ignore non-contact before index` parameter to
65 the index of the last surface- or protein-related feature.
67 _fit_check_arguments = [
68 Argument('min slope ratio', type='float', default=10.,
70 Minimum `contact-slope/non-contact-slope` ratio for a "good" fit.
72 Argument('min contact fraction', type='float', default=0.02,
74 Minimum fraction of points in the contact region for a "good" fit.
76 Argument('max contact fraction', type='float', default=0.98,
78 Maximum fraction of points in the contact region for a "good" fit.
80 Argument('min slope guess ratio', type='float', default=0.5,
82 Minimum `fit-contact-slope/guessed-contact-slope` ratio for a "good" fit.
86 def model(self, params):
87 """A continuous, bilinear model.
94 p_0 + p_1 x & \text{if $x <= p_2$}, \\
95 p_0 + p_1 p_2 + p_3 (x-p_2) & \text{if $x >= p_2$}.
98 Where :math:`p_0` is a vertical offset, :math:`p_1` is the slope
99 of the first region, :math:`p_2` is the transition location, and
100 :math:`p_3` is the slope of the second region.
102 p = params # convenient alias
103 rNC_ignore = self.info['ignore non-contact before index']
104 if self.info['force zero non-contact slope'] is True:
106 p.append(0.) # restore the non-contact slope parameter
107 r2 = numpy.round(abs(p[2]))
109 r2 = min(r2, len(self._model_data))
110 self._model_data[:r2] = p[0] + p[1] * numpy.arange(r2)
111 if r2 < len(self._data)-1:
112 self._model_data[r2:] = \
113 p[0] + p[1]*p[2] + p[3] * numpy.arange(len(self._data)-r2)
115 self._model_data[r2:rNC_ignore] = self._data[r2:rNC_ignore]
116 return self._model_data
118 def set_data(self, data, info=None, *args, **kwargs):
119 super(SurfacePositionModel, self).set_data(data, info, *args, **kwargs)
122 if getattr(self, 'info', None) == None:
124 self.info.update(info)
126 ('force zero non-contact slope', False),
127 ('ignore non-contact before index', -1),
128 ('min position', 0), # Store postions etc. to avoid recalculating.
129 ('max position', len(data)),
130 ('max deflection', data.max()),
131 ('min deflection', data.min()),
133 if key not in self.info:
134 self.info[key] = value
137 self.info['max position'] - self.info['min position']),
139 self.info['max deflection'] - self.info['min deflection']),
141 if key not in self.info:
142 self.info[key] = value
143 for argument in self._fit_check_arguments:
144 if argument.name not in self.info:
145 self.info[argument.name] = argument.default
147 def guess_initial_params(self, outqueue=None):
148 """Guess the initial parameters.
152 We guess initial parameters such that the offset (:math:`p_1`)
153 matches the minimum deflection, the kink (:math:`p_2`) occurs
154 at the first point that the deflection crosses the middle of
155 its range, the initial (contact) slope (:math:`p_0`) produces
156 the right-most deflection at the kink point, and the final
157 (non-contact) slope (:math:`p_3`) is zero.
159 In the event of a tie, :meth:`argmax` returns the lowest index
160 to the maximum value.
161 >>> (numpy.arange(10) >= 5).argmax()
164 left_offset = self.info['min deflection']
165 middle_deflection = (self.info['min deflection']
166 + self.info['deflection range']/2.)
167 kink_position = 2*(self._data > middle_deflection).argmax()
168 if kink_position == 0:
169 # jump vibration at the start of the retraction?
170 start = int(min(max(20, 0.01 * len(self._data)), 0.5*len(self._data)))
171 std = self._data[:start].std()
172 left_offset = self._data[start].mean()
174 while abs(self._data[stop] - left_offset) < 3*std:
176 left_slope = (self._data[stop-start:stop].mean()
177 - left_offset) / (stop-start)
178 left_offset -= left_slope * start/2
179 kink_position = (self._data[-1] - left_offset)/left_slope
181 left_slope = (self._data[-1] - self.info['min deflection']
184 self.info['guessed contact slope'] = left_slope
185 params = [left_offset, left_slope, kink_position, right_slope]
186 if self.info['force zero non-contact slope'] == True:
190 def fit(self, *args, **kwargs):
191 """Fit the model to the data.
195 We change the `epsfcn` default from :func:`scipy.optimize.leastsq`'s
196 `0` to `1e-3`, so the initial Jacobian estimate takes larger steps,
197 which helps avoid being trapped in noise-generated local minima.
199 self.info['guessed contact slope'] = None
200 if 'epsfcn' not in kwargs:
201 kwargs['epsfcn'] = 1e-3 # take big steps to estimate the Jacobian
202 params = super(SurfacePositionModel, self).fit(*args, **kwargs)
203 params[2] = abs(params[2])
204 if self.info['force zero non-contact slope'] == True:
205 params = list(params)
206 params.append(0.) # restore the non-contact slope parameter
208 # check that the fit is reasonable, see the :meth:`model` docstring
209 # for parameter descriptions.
210 slope_ratio = abs(params[1]/params[3])
211 if slope_ratio < self.info['min slope ratio']:
213 'Slope in non-contact region, or no slope in contact (slope ratio %g less than %g)'
214 % (slope_ratio, self.info['min slope ratio']))
215 contact_fraction = ((params[2]-self.info['min position'])
216 /self.info['position range'])
217 if contact_fraction < self.info['min contact fraction']:
219 'No kink (contact fraction %g less than %g)'
220 % (contact_fraction, self.info['min contact fraction']))
221 if contact_fraction > self.info['max contact fraction']:
223 'No kink (contact fraction %g greater than %g)'
224 % (contact_fraction, self.info['max contact fraction']))
225 slope_guess_ratio = abs(params[1]/self.info['guessed contact slope'])
226 if (self.info['guessed contact slope'] != None
227 and slope_guess_ratio < self.info['min slope guess ratio']):
229 'Too far (contact slope off guess by %g less than %g)'
230 % (slope_guess_ratio, self.info['min slope guess ratio']))
234 class VelocityClampPlugin (Plugin):
236 super(VelocityClampPlugin, self).__init__(name='vclamp')
238 SurfaceContactCommand(self), ForceCommand(self),
239 CantileverAdjustedExtensionCommand(self), FlattenCommand(self),
242 def default_settings(self):
244 Setting(section=self.setting_section, help=self.__doc__),
245 Setting(section=self.setting_section,
246 option='surface contact point algorithm',
248 help='Select the surface contact point algorithm. See the documentation for descriptions of available algorithms.')
252 class SurfaceContactCommand (ColumnAddingCommand):
253 """Automatically determine a block's surface contact point.
255 You can select the contact point algorithm with the creatively
256 named `surface contact point algorithm` configuration setting.
257 Currently available options are:
259 * fmms (:meth:`find_contact_point_fmms`)
260 * ms (:meth:`find_contact_point_ms`)
261 * wtk (:meth:`find_contact_point_wtk`)
263 def __init__(self, plugin):
264 self._wtk_fit_check_arguments = []
265 for argument in SurfacePositionModel._fit_check_arguments:
266 arg = copy.deepcopy(argument)
267 arg._help += ' (wtk model)'
268 self._wtk_fit_check_arguments.append(arg)
269 super(SurfaceContactCommand, self).__init__(
270 name='zero surface contact point',
272 ('distance column', 'z piezo (m)', """
273 Name of the column to use as the surface position input.
275 ('deflection column', 'deflection (m)', """
276 Name of the column to use as the deflection input.
280 ('output distance column', 'surface distance', """
281 Name of the column (without units) to use as the surface position output.
283 ('output deflection column', 'surface deflection', """
284 Name of the column (without units) to use as the deflection output.
288 Argument(name='ignore index', type='int', default=None,
290 Ignore the residual from the non-contact region before the indexed
291 point (for the `wtk` algorithm).
293 Argument(name='ignore after last peak info name',
294 type='string', default=None,
296 As an alternative to 'ignore index', ignore after the last peak in the
297 peak list stored in the `.info` dictionary.
299 Argument(name='force zero non-contact slope', type='bool',
300 default=False, count=1,
302 Fix the fitted non-contact slope at zero.
304 Argument(name='distance info name', type='string',
305 default='surface distance offset',
307 Name (without units) for storing the distance offset in the `.info` dictionary.
309 Argument(name='deflection info name', type='string',
310 default='surface deflection offset',
312 Name (without units) for storing the deflection offset in the `.info` dictionary.
314 Argument(name='fit parameters info name', type='string',
315 default='surface deflection offset',
317 Name (without units) for storing fit parameters in the `.info` dictionary.
319 ] + self._wtk_fit_check_arguments,
320 help=self.__doc__, plugin=plugin)
322 def _run(self, hooke, inqueue, outqueue, params):
323 self._add_to_command_stack(params)
324 params = self._setup_params(hooke=hooke, params=params)
325 block = self._block(hooke=hooke, params=params)
326 dist_data = self._get_column(hooke=hooke, params=params,
327 column_name='distance column')
328 def_data = self._get_column(hooke=hooke, params=params,
329 column_name='deflection column')
330 i,def_offset,ps = self.find_contact_point(
331 params, dist_data, def_data, outqueue)
332 dist_offset = dist_data[i]
333 block.info[params['distance info name']] = dist_offset
334 block.info[params['deflection info name']] = def_offset
335 block.info[params['fit parameters info name']] = ps
336 self._set_column(hooke=hooke, params=params,
337 column_name='output distance column',
338 values=dist_data - dist_offset)
339 self._set_column(hooke=hooke, params=params,
340 column_name='output deflection column',
341 values=def_data - def_offset)
343 def _setup_params(self, hooke, params):
344 name,dist_unit = split_data_label(params['distance column'])
345 name,def_unit = split_data_label(params['deflection column'])
346 params['output distance column'] = join_data_label(
347 params['output distance column'], dist_unit)
348 params['output deflection column'] = join_data_label(
349 params['output deflection column'], def_unit)
350 params['distance info name'] = join_data_label(
351 params['distance info name'], dist_unit)
352 params['deflection info name'] = join_data_label(
353 params['deflection info name'], def_unit)
356 def find_contact_point(self, params, z_data, d_data, outqueue=None):
357 """Railyard for the `find_contact_point_*` family.
359 Uses the `surface contact point algorithm` configuration
360 setting to call the appropriate backend algorithm.
362 fn = getattr(self, 'find_contact_point_%s'
363 % self.plugin.config['surface contact point algorithm'])
364 return fn(params, z_data, d_data, outqueue)
366 def find_contact_point_fmms(self, params, z_data, d_data, outqueue=None):
367 """Algorithm by Francesco Musiani and Massimo Sandal.
373 0) Driver-specific workarounds, e.g. deal with the PicoForce
374 trigger bug by excluding retraction portions with excessive
376 1) Select the second half (non-contact side) of the retraction
378 2) Fit the selection to a line.
379 3) If the fit is not almost horizontal, halve the selection
381 4) Average the selection and use it as a baseline.
382 5) Slide in from the start (contact side) of the retraction
383 curve, until you find a point with greater than baseline
384 deflection. That point is the contact point.
386 if params['curve'].info['filetype'] == 'picoforce':
387 # Take care of the picoforce trigger bug (TODO: example
388 # data file demonstrating the bug). We exclude portions
389 # of the curve that have too much standard deviation.
390 # Yes, a lot of magic is here.
391 check_start = len(d_data)-len(d_data)/20
392 monster_start = len(d_data)
394 # look at the non-contact tail
395 non_monster = d_data[check_start:monster_start]
396 if non_monster.std() < 2e-10: # HACK: hardcoded cutoff
398 else: # move further away from the monster
399 check_start -= len(d_data)/50
400 monster_start -= len(d_data)/50
401 z_data = z_data[:monster_start]
402 d_data = d_data[:monster_start]
404 # take half of the thing to start
405 selection_start = len(d_data)/2
407 z_chunk = z_data[selection_start:]
408 d_chunk = d_data[selection_start:]
409 slope,intercept,r,two_tailed_prob,stderr_of_the_estimate = \
410 scipy.stats.linregress(z_chunk, d_chunk)
411 # We stop if we found an almost-horizontal fit or if we're
412 # getting to small a selection. FIXME: 0.1 and 5./6 here
413 # are "magic numbers" (although reasonable)
414 if (abs(slope) < 0.1 # deflection (m) / surface (m)
415 or selection_start > 5./6*len(d_data)):
417 selection_start += 10
419 d_baseline = d_chunk.mean()
421 # find the first point above the calculated baseline
423 while i < len(d_data) and d_data[i] < ymean:
425 return (i, d_baseline, {})
427 def find_contact_point_ms(self, params, z_data, d_data, outqueue=None):
428 """Algorithm by Massimo Sandal.
432 WTK: At least the commits are by Massimo, and I see no notes
433 attributing the algorithm to anyone else.
439 xext=raw_plot.vectors[0][0]
440 yext=raw_plot.vectors[0][1]
441 xret2=raw_plot.vectors[1][0]
442 yret=raw_plot.vectors[1][1]
444 first_point=[xext[0], yext[0]]
445 last_point=[xext[-1], yext[-1]]
447 #regr=scipy.polyfit(first_point, last_point,1)[0:2]
448 diffx=abs(first_point[0]-last_point[0])
449 diffy=abs(first_point[1]-last_point[1])
451 #using polyfit results in numerical errors. good old algebra.
453 b=first_point[1]-(a*first_point[0])
454 baseline=scipy.polyval((a,b), xext)
456 ysub=[item-basitem for item,basitem in zip(yext,baseline)]
458 contact=ysub.index(min(ysub))
460 return xext,ysub,contact
462 #now, exploit a ClickedPoint instance to calculate index...
464 dummy.absolute_coords=(x_intercept,y_intercept)
465 dummy.find_graph_coords(xret2,yret)
468 return dummy.index, regr, regr_contact
472 def find_contact_point_wtk(self, params, z_data, d_data, outqueue=None):
473 """Algorithm by W. Trevor King.
477 Uses :class:`SurfacePositionModel` internally.
479 reverse = z_data[0] > z_data[-1]
480 if reverse == True: # approaching, contact region on the right
481 d_data = d_data[::-1]
482 s = SurfacePositionModel(d_data, info={
483 'force zero non-contact slope':
484 params['force zero non-contact slope']},
486 for argument in self._wtk_fit_check_arguments:
487 s.info[argument.name] = params[argument.name]
489 if params['ignore index'] != None:
490 ignore_index = params['ignore index']
491 elif params['ignore after last peak info name'] != None:
492 peaks = z_data.info[params['ignore after last peak info name']]
493 if not len(peaks) > 0:
494 raise Failure('Need at least one peak in %s, not %s'
495 % (params['ignore after last peak info name'],
497 ignore_index = peaks[-1].post_index()
498 if ignore_index != None:
499 s.info['ignore non-contact before index'] = ignore_index
500 offset,contact_slope,surface_index,non_contact_slope = s.fit(
502 deflection_offset = offset + contact_slope*surface_index
503 delta_pos_per_point = z_data[1] - z_data[0]
504 contact_slope /= delta_pos_per_point # ddef/point -> ddev/dpos
505 non_contact_slope /= delta_pos_per_point
508 'contact slope': contact_slope,
509 'surface index': surface_index,
510 'non-contact slope': non_contact_slope,
514 surface_index = len(d_data)-1-surface_index
515 return (numpy.round(surface_index), deflection_offset, info)
518 class ForceCommand (ColumnAddingCommand):
519 """Convert a deflection column from meters to newtons.
521 def __init__(self, plugin):
522 super(ForceCommand, self).__init__(
523 name='convert distance to force',
525 ('deflection column', 'surface deflection (m)', """
526 Name of the column to use as the deflection input.
530 ('output deflection column', 'deflection', """
531 Name of the column (without units) to use as the deflection output.
535 Argument(name='spring constant info name', type='string',
536 default='spring constant (N/m)',
538 Name of the spring constant in the `.info` dictionary.
541 help=self.__doc__, plugin=plugin)
543 def _run(self, hooke, inqueue, outqueue, params):
544 self._add_to_command_stack(params)
545 params = self._setup_params(hooke=hooke, params=params)
546 # TODO: call .curve.ScaledColumnAdditionCommand
547 def_data = self._get_column(hooke=hooke, params=params,
548 column_name='deflection column')
549 out = def_data * def_data.info[params['spring constant info name']]
550 self._set_column(hooke=hooke, params=params,
551 column_name='output deflection column',
554 def _setup_params(self, hooke, params):
555 name,in_unit = split_data_label(params['deflection column'])
556 out_unit = 'N' # HACK: extract target units from k_unit.
557 params['output deflection column'] = join_data_label(
558 params['output deflection column'], out_unit)
559 name,k_unit = split_data_label(params['spring constant info name'])
560 expected_k_unit = '%s/%s' % (out_unit, in_unit)
561 if k_unit != expected_k_unit:
562 raise Failure('Cannot convert from %s to %s with %s'
563 % (params['deflection column'],
564 params['output deflection column'],
565 params['spring constant info name']))
569 class CantileverAdjustedExtensionCommand (ColumnAddingCommand):
570 """Remove cantilever extension from a total extension column.
572 If `distance column` and `deflection column` have the same units
573 (e.g. `z piezo (m)` and `deflection (m)`), `spring constant info
574 name` is ignored and a deflection/distance conversion factor of
577 def __init__(self, plugin):
578 super(CantileverAdjustedExtensionCommand, self).__init__(
579 name='remove cantilever from extension',
581 ('distance column', 'surface distance (m)', """
582 Name of the column to use as the surface position input.
584 ('deflection column', 'deflection (N)', """
585 Name of the column to use as the deflection input.
589 ('output distance column', 'cantilever adjusted extension', """
590 Name of the column (without units) to use as the surface position output.
594 Argument(name='spring constant info name', type='string',
595 default='spring constant (N/m)',
597 Name of the spring constant in the `.info` dictionary.
600 help=self.__doc__, plugin=plugin)
602 def _run(self, hooke, inqueue, outqueue, params):
603 self._add_to_command_stack(params)
604 params = self._setup_params(hooke=hooke, params=params)
605 def_data = self._get_column(hooke=hooke, params=params,
606 column_name='deflection column')
607 dist_data = self._get_column(hooke=hooke, params=params,
608 column_name='distance column')
609 if params['spring constant info name'] == None:
610 k = 1.0 # distance and deflection in the same units
612 k = def_data.info[params['spring constant info name']]
613 self._set_column(hooke=hooke, params=params,
614 column_name='output distance column',
615 values=dist_data - def_data / k)
617 def _setup_params(self, hooke, params):
618 name,dist_unit = split_data_label(params['distance column'])
619 name,def_unit = split_data_label(params['deflection column'])
620 params['output distance column'] = join_data_label(
621 params['output distance column'], dist_unit)
622 if dist_unit == def_unit:
623 params['spring constant info name'] == None
625 name,k_unit = split_data_label(params['spring constant info name'])
626 expected_k_unit = '%s/%s' % (def_unit, dist_unit)
627 if k_unit != expected_k_unit:
628 raise Failure('Cannot convert from %s to %s with %s'
629 % (params['deflection column'],
630 params['output distance column'],
631 params['spring constant info name']))
635 class FlattenCommand (ColumnAddingCommand):
636 """Flatten a deflection column.
638 Subtracts a polynomial fit from the non-contact part of the curve
639 to flatten it. The best polynomial fit is chosen among
640 polynomials of degree 1 to `max degree`.
642 .. todo: Why does flattening use a polynomial fit and not a sinusoid?
643 Isn't most of the oscillation due to laser interference?
644 See Jaschke 1995 ( 10.1063/1.1146018 )
645 and the figure 4 caption of Weisenhorn 1992 ( 10.1103/PhysRevB.45.11226 )
647 def __init__(self, plugin):
648 super(FlattenCommand, self).__init__(
649 name='polynomial flatten',
651 ('distance column', 'surface distance (m)', """
652 Name of the column to use as the surface position input.
654 ('deflection column', 'deflection (N)', """
655 Name of the column to use as the deflection input.
659 ('output deflection column', 'flattened deflection', """
660 Name of the column (without units) to use as the deflection output.
664 Argument(name='degree', type='int',
667 Order of the polynomial used for flattening. Using values greater
668 than one usually doesn't help and can give artifacts. However, it
669 could be useful too. (TODO: Back this up with some theory...)
671 Argument(name='fit info name', type='string',
672 default='flatten fit',
674 Name of the flattening information in the `.info` dictionary.
677 help=self.__doc__, plugin=plugin)
679 def _run(self, hooke, inqueue, outqueue, params):
680 self._add_to_command_stack(params)
681 params = self._setup_params(hooke=hooke, params=params)
682 block = self._block(hooke=hooke, params=params)
683 dist_data = self._get_column(hooke=hooke, params=params,
684 column_name='distance column')
685 def_data = self._get_column(hooke=hooke, params=params,
686 column_name='deflection column')
687 degree = params['degree']
689 indices = numpy.argwhere(mask)
690 if len(indices) == 0:
691 raise Failure('no positive distance values in %s'
692 % params['distance column'])
693 dist_nc = dist_data[indices].flatten()
694 def_nc = def_data[indices].flatten()
697 poly_values = scipy.polyfit(dist_nc, def_nc, degree)
698 def_nc_fit = scipy.polyval(poly_values, dist_nc)
700 raise Failure('failed to flatten with a degree %d polynomial: %s'
702 error = numpy.sqrt((def_nc_fit-def_nc)**2).sum() / len(def_nc)
703 block.info[params['fit info name']] = {
706 'polynomial values':poly_values,
709 - numpy.invert(mask)*scipy.polyval(poly_values[-1:], dist_data)
710 - mask*scipy.polyval(poly_values, dist_data))
711 self._set_column(hooke=hooke, params=params,
712 column_name='output deflection column',
715 def _setup_params(self, hooke, params):
716 d_name,d_unit = split_data_label(params['deflection column'])
717 params['output deflection column'] = join_data_label(
718 params['output deflection column'], d_unit)