From e11cbc5fa7c94d76ad41942f7f44758130869e48 Mon Sep 17 00:00:00 2001 From: devicerandom Date: Thu, 24 Apr 2008 13:49:48 +0000 Subject: [PATCH] Initial SVN upload --- CHANGELOG | 391 +++++++++++++++++++++ convfilt.conf | 20 ++ csvdriver.py | 75 ++++ fit.py | 440 +++++++++++++++++++++++ flatfilts.py | 421 ++++++++++++++++++++++ generalclamp.py | 101 ++++++ generalvclamp.py | 498 ++++++++++++++++++++++++++ hemingclamp.py | 123 +++++++ hooke.conf | 51 +++ hooke.jpg | Bin 0 -> 116617 bytes hooke.py | 752 +++++++++++++++++++++++++++++++++++++++ hooke_cli.py | 871 ++++++++++++++++++++++++++++++++++++++++++++++ libhooke.py | 317 +++++++++++++++++ libhookecurve.py | 136 ++++++++ libpeakspot.py | 124 +++++++ macro.py | 231 ++++++++++++ massanalysis.py | 143 ++++++++ picoforce.py | 546 +++++++++++++++++++++++++++++ procplots.py | 251 +++++++++++++ superimpose.py | 98 ++++++ tutorial.py | 544 +++++++++++++++++++++++++++++ tutorialdriver.py | 208 +++++++++++ 22 files changed, 6341 insertions(+) create mode 100755 CHANGELOG create mode 100644 convfilt.conf create mode 100644 csvdriver.py create mode 100755 fit.py create mode 100755 flatfilts.py create mode 100644 generalclamp.py create mode 100644 generalvclamp.py create mode 100755 hemingclamp.py create mode 100755 hooke.conf create mode 100755 hooke.jpg create mode 100755 hooke.py create mode 100755 hooke_cli.py create mode 100755 libhooke.py create mode 100755 libhookecurve.py create mode 100644 libpeakspot.py create mode 100644 macro.py create mode 100644 massanalysis.py create mode 100755 picoforce.py create mode 100755 procplots.py create mode 100644 superimpose.py create mode 100755 tutorial.py create mode 100644 tutorialdriver.py diff --git a/CHANGELOG b/CHANGELOG new file mode 100755 index 0000000..bc807fa --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,391 @@ +0.8.4 +(2008-x-x) + PLUGINS: + macro.py: + hooke does not crash if it doesn't have permissions to create the folder + +0.8.3 +(2008-04-16) + PLUGINS: + generalvclamp.py: + fixed autopeak header + fixed autopeak slope (now unwanted slope values are discarded) + +0.8.2 +(2008-04-10) + PLUGINS: + flatfilts.py: + convfilt does not crash if a file is not a curve + generalvclamp.py: + autopeak now saves curve data correctly + autopeak now generates a dummy note (so that copylog/notelog is aware you measured the curve) + +0.8.1 +(2008-04-07) + PLUGINS: + generalvclamp.py: + fixed DeprecationWarning in flatten + flatfilts.py + convfilt now working + + +0.8.0: +(2008-04-04) + hooke.py: + sanity check of CLI plugins to avoid function overloading at startup + hooke_cli.py ; libhooke.py: + now playlists keep the index (when you reload the playlist, it starts from the + last observed curve) + updated plot to use _send_plot() + hooke.conf accepts lists as arguments for variables in + txt, export now have consistent argument order (thanks to A.G.Casado for pointing me that) + txt crashes no more if no filename is given (thanks to A.G.Casado for pointing me that) + libhookecurve.py: + added add_set() , remove_set() methods to make life easier for plugin writers + procplots.py: + plotmanip_correct() works with new picoforce.py deflection output (see) + PLUGINS: + fit.py: + updated wlc to use _send_plot() + wlc noauto now keeps the contact point + wlc reclick to click again the contact point + temperature now set in hooke.conf + generalvclamp.py: + implemented slope (thanks to Marco Brucale) + implemented autopeak + flatfilts.py: + convfilt,peaks use flattened curve + macro.py: + (new) added macro plugin (thanks to Alberto Gomez Casado) + DRIVERS: + picoforce.py: + fixed trigger bug! (thanks to Alberto Gomez Casado) + better deflection output (separated extension,retraction) + +0.7.5: +(2008-03-27) + hooke_cli.py: + removed outdated size command + PLUGINS: + generalvclamp.py: + implemented flatten + DRIVERS: + added tutorialdriver.py driver + csvdriver.py: + fixed (forgot close_all() method) + +0.7.4: +(2008-03-19) + added csvdriver driver + hooke_cli.py: + fixed plot manipulators handling (now it's safe to comment a + plot manipulator on hooke.conf) + PLUGINS: + fit.py: + fixed possible crash when clicking two times the same point on wlc + +0.7.3: +(2008-01-10) + hooke_cli.py: + fixed crash on copylog + PLUGINS: + massanalysis.py: + Initial release + tutorial.py: + Tutorial plugin, initial release + +0.7.2.1: +(2007-11-30) + PLUGINS: + flatfilt.py: + fixed crash on Windows + +0.7.2: +(2007-11-29) + hooke.py: + new configuration variable hookedir + hooke_cli.py: + copylog now checks if the destination is a real directory + fixed crashes in set + PLUGINS: + generalvclamp.py: + fixed a crash in forcebase when picking two times the same point + flatfilt.py: + fixed crash due to convfilt.conf impossible to load + initial implementation of the blind window for convfilt + initial data set maps (NOT FINISHED) + +0.7.1: +(2007-11-26) + PLUGINS: + flatfilts.py: + fixed possible crash in convfilt + implemented configuration file convfilt.conf + convfilt defaults are now 5 peaks 5 times more the noise absdev + implemented convconf + implemented setconf + libpeakspot.py: + fixed:now it really uses noise_absdev + +0.7.0: +(2007-11-15) + hooke_cli.py: + implemented _send_plot() helper API function + PLUGINS: + generalvclamp.py: + fixed forcebase to work with subtplot + flatfilts.py: + implemented convfilt! + added libpeakspot.py (helping library for convolution filter) + +0.6.5: +(2007-11-06) + hooke_cli.py, hooke.py: + plateau and contact (unmaintained) deleted and scheduled for re-release in generalvramp + implemented _measure_N_points() + PLUGINS: + generalvclamp.py: + implemented forcebase + fit.py: + wlc now accepts and uses temperature as an argument + wlc has been cleaned and uses new APIs + +0.6.4: +(2007-10-23) + hooke_cli.py, libhooke.py: + implemented support for defining order of plotmanip methods in hooke.conf + hooke_cli.py: + implemented delta + implemented point + attempted fix to bug 0033 (notelog crashing Hooke when using Unicode characters) + PLUGINS: + generalvramp.py: + began to move velocity ramp force spectroscopy-specific things in separate plugin + procplots.py: + added detriggerize; "set detrigger" 0/1 disables/enables it. + DRIVERS: + picoforce.py: + removed detriggerize() from driver + +0.6.3: +(2007-10-02) + hooke_cli.py: + rewritten txt command, now working + DRIVERS: + picoforce.py: + implemented detriggerize() to bypass the Picoforce trigger bug + PLUGINS: + superimpose.py: + implemented plotavgimpose + +0.6.2: +(2007-09-27) + hooke_cli.py: + fixed error handling in notelog + smarter handling of directory names in genlist + unexpected error handling in do_plot() + hooke.py: + implemented GetDisplayedPlot event and handlers + PLUGINS: + fit.py: + fixed (bug 0029) about replotting of wlc on a subtplot curve + multiple fitting displayed (to refine...) + +0.6.1: +(2007-08-06) + libhooke.py , hooke.py: + initial support for workdir configuration variable + libhooke.py: + fixed Driver() etc. semantics for gracefully handling unrecognized plots + hooke_cli.py: + fixed export namehandling + fixed plot error handling + PLUGINS: + flatfilts.py: + fixed memory leak + generalclamp.py: + fixed step command + +0.6.0 "Anko": +(2007-07-25) + hooke.py: + initial plugin support for the gui + wlc fitting now 100% plugin + measure_points replaces measure_couple etc. and provides much better extensibility + hooke_cli.py: + curves are sorted at beginning + PLUGINS: + procplots.py: + fft now allows for user selection of curve segment; select the plot; etc. + fit.py: + added gui section of plugin, now completely independent + fixed bug of wlc output + superimpose.py: + new plugin for superimposition of curve segments (still in development) + generalclamp.py: + all clamp commands now in a single plugin + implemented step + +0.5.4: +(2007-06-15) + procplots.py: + fixed fft crash with Numpy 1.0.1 + hooke.py: + fixed crashes if plot.scatter[] was empty + fixed management of multiple plots (bug #0025) + hooke_cli.py + fixed zpiezo error in measurement + hemingclamp.py, picoforce.py: + implemented close_all() method in drivers to avoid too many open files error + flatfilts.py: + fixed memory leak +0.5.3: +(2007-06-06) + wlc.py, hooke.py: + fixing and cleaning fit code: now the fit is part of a PlotObject and 100% coded in wlc.py + plotting of the wlc.py clicked points also begin to be part of a PlotObject + management of 'scatter' style property of plots + hooke_cli.py + fixed measuring error in defl, zpiezo + flatfilts.py: + slightly optimized has_features() routine + procplots.py: + fixed derivplot for every number of vectors + fixed possible crash of subtplot if applied on a file with != 2 plots + added fft command + libhookecurve.py: + fixed xaxis, yaxis for non-default plots: now defined from PlotObject + PlotObject now defines a styles[] vector +0.5.2: +(2007-05-21) + versioning a bit cleaned + fixed bug in hemingclamp.py preventing filename to appear + fixed wxversion problem for 2.8 + fixed too many open files bug (bug 0024) + added index command +0.5.1: +(2007-05-09) + using wxversion to choose from multiple wx versions + fixed old dependencies remaining +0.5.0 "Ingyo": +(2007-05-03) + general code updating and rewriting due to plugin support/better plot management + hooke.py: + initial plugin architecture for the command line. + initial plugin architecture for file drivers + initial plugin architecture for processing plots + export can now export both top and bottom plot (not together) + hooke_cli.py: + wlc fitting moved to fit.py plugin + flatfilt moved to flatfilts.py plugin + subtplot, derivplot moved to procplots.py plugin + double plot temporarily fixed for previous commands + export can now export both top and bottom plot (not together) + +0.4.1: +(2007-02-13) + hooke_cli.py: + double plot now default for clamp experiments + libhooke.py: + fixed bug that prevented flatfilt to work + (maybe) fixed memory leak in flatfilt + +0.4.0 "Hanzei": +(2007-02-08) + general code updating and rewriting due to double plot/force clamp supports + hooke.py: + initial dummy menu sketch + hooke.py, hooke_cli.py: + first general support in code for double plot: + - derivplot now in separate plot + - implemented show and close commands + - all functions should be double plot-aware + - clicking a point is double plot-aware + libhooke.py, hooke_cli.py: + general code cleanup: vectors_to_plot(), subtract_plot(), find_contact_point() and derivplot routines are now methods of class HookeCurve + hooke_cli.py: + implemented quit (alias of exit) + implemented version + libhooke.py, hooke.py, hooke_cli.py: + initial support for force clamp experiments: + - hemingclamp driver supported + - "experiment" flag describes what kind of experiment is a curve + - time, zpiezo, defl commands implemented + libhemingclamp.py: + inital release. + +0.3.1: + hooke.py: + fixed stupid bug in plateau + fixed bug in derivplot and subtplot not taking into account xaxes/yaxes variables +0.3.0: + from now on, all changelog is stored in CHANGELOG + hooke.py, libhooke.py, hooke_cli.py: + fixed plot and flatfilt crash when processing corrupt files + flatfilt output now more verbose + implemented system (execute an external OS command) + implemented copylog (copies annotated curves to a given directory) (todo 0033) + initial txt implementation (exports the current curve as a text file) (todo 0023) + fixed exit behaviour (bug 0013) + xaxes and yaxes variables now control visualization of plot (todo 0018) + new (better) contact point algorithm + workaround for the picoforce trigger bug +0.2.2 : + hooke.py, hooke_cli.py, libhooke.py: + support for fixed persistent length in WLC +0.2.1 : + hooke.py , libhooke.py: + fixed 'wlc noauto' bug (0012) preventing correct contact point to be used +0.2.0 : + hooke_cli.py: + implemented getlist (alias of genlist) + implemented contact (to plot the contact point) + fixed bug 0001 (Hooke crashes when opening a non-pf file) + fixed bug 0008 (Hooke crashes when generating a playlist with malformed namefiles/nonexistent files) + now the plot is refreshed after a "set" command (todo 0014) + wlc fit can use the (new) automatic contact point detection (old behaviour is preserved with "noauto" option) + hooke.py: + fixed versioning printing + complete refactoring of contact point routines + wlc fit adapted to use the (new) automatic contact point detection + wlc fit code a bit cleaned; parts moved to libhooke.py + libhooke.py: + new contact point algorithm (new algorithm) + wlc fit now uses a fancier domain (from contact point to a bit more than last point); initial chunk preparation section moved from hooke.py + + +OLDER CHANGELOGS: + +hooke.py: +0.1.1 : + From now on, all changelog is stored in hooke.py + hooke_cli.py: + corrected bug 0010 (addtolist bug), alerts when hitting start/end of playlist +2006_09_15_devel=0.1.0: initial WLC fit support. We hit 0.1 milestone :D +2006_08_28_devel: refactoring of plot interaction +2006_06_14_devel: fixed libhooke calls +2006_06_08_devel: initial automatic contact point finding +2006_05_30_devel: configuration file support + +hooke_cli.py: +0.1.1 : from now on, all changelog is in hooke.py +2006_09_15_devel: implemented wlc; 0.1.0 milestone. +2006_08_28_devel: refactoring of plot interaction +2006_07_23_devel: implemented note; implemented flatfilt; implemented notelog; exit now warns if playlist/notes + have not been saved. +2006_07_18_devel: implemented subtplot; bug 0007 ("cd" crashing) fixed +2006_06_16_devel: moved math helper functions in libhooke.py +2006_06_14_devel: fixed "jump" output; fixed "exit" (now it works!); fixed off-by-one bug in deflection-correction +2006_06_08_devel: fixed "loadlist" output; +2006_05_30_devel: initial configuration file support; added "set" command; initial deflection-correction support; added "ls" command as an alias of "dir" +2006_05_23_devel: rewriting of playlist-handling code due to major rewrite of hooke_playlist.py + +libhooke.py +0.1.1 : from now on, all changelog is in hooke.py +2006_09_15_devel : initial WLC support +2006_09_14_devel : initial support for Hemingway velocity clamp files, minor refactorings +2006_07_22_devel : implemented math function has_features +2006_06_16_devel : math functions moved here +2006_06_08_devel : hooke_playlist.py becomes libhooke.py +2006_05_30_devel : support for deflection in HookeCurve +2006_05_29_devel : Initial configuration file support +2006_05_23_devel : Major rewrite. Fixed bug 0002 \ No newline at end of file diff --git a/convfilt.conf b/convfilt.conf new file mode 100644 index 0000000..ef5254c --- /dev/null +++ b/convfilt.conf @@ -0,0 +1,20 @@ + + + + + + + + + + \ No newline at end of file diff --git a/csvdriver.py b/csvdriver.py new file mode 100644 index 0000000..4222255 --- /dev/null +++ b/csvdriver.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python + +''' +csvdriver.py + +Simple driver to read general comma-separated values in Hooke + +Columns are read this way: + +X1 , Y1 , X2 , Y2 , X3 , Y3 ... + +If the number of columns is odd, the last column is ignored. + +(c)Massimo Sandal, 2008 +''' + +import libhookecurve as lhc +import libhooke as lh +import csv + +class csvdriverDriver(lhc.Driver): + + def __init__(self, filename): + + self.filedata = open(filename,'r') + self.data = list(self.filedata) + self.filedata.close() + + self.filetype = 'generic' + self.experiment = '' + + self.filename=filename + + def is_me(self): + myfile=file(self.filename) + headerline=myfile.readlines()[0] + myfile.close() + + #using a custom header makes things much easier... + #(looking for raw CSV data is at strong risk of confusion) + if headerline[:-1]=='Hooke data': + return True + else: + return False + + def close_all(self): + self.filedata.close() + + def default_plots(self): + rrows=csv.reader(self.data) + rows=list(rrows) #transform the csv.reader iterator in a normal list + columns=lh.transposed2(rows[1:]) + + main_plot=lhc.PlotObject() + main_plot.vectors=[] + + for index in range(0,len(columns),2): + main_plot.vectors.append([]) + temp_x=columns[index] + temp_y=columns[index+1] + + #convert to float (the csv gives strings) + temp_x=[float(item) for item in temp_x] + temp_y=[float(item) for item in temp_y] + + main_plot.vectors[-1].append(temp_x) + main_plot.vectors[-1].append(temp_y) + + main_plot.units=['x','y'] + main_plot.title=self.filename + main_plot.destination=0 + + return [main_plot] + + \ No newline at end of file diff --git a/fit.py b/fit.py new file mode 100755 index 0000000..b8f4717 --- /dev/null +++ b/fit.py @@ -0,0 +1,440 @@ +#!/usr/bin/env python + +''' +FIT + +Force spectroscopy curves basic fitting plugin. +Licensed under the GNU GPL version 2 + +Non-standard Dependencies: +procplots.py (plot processing plugin) +''' +from libhooke import WX_GOOD, ClickedPoint +import wxversion +wxversion.select(WX_GOOD) +#from wx import PostEvent +#from wx.lib.newevent import NewEvent +import scipy +import numpy as np +import copy +import Queue + +global measure_wlc +global EVT_MEASURE_WLC + +#measure_wlc, EVT_MEASURE_WLC = NewEvent() + +global events_from_fit +events_from_fit=Queue.Queue() #GUI ---> CLI COMMUNICATION + + +class fitCommands: + + def _plug_init(self): + self.wlccurrent=None + self.wlccontact_point=None + self.wlccontact_index=None + + def wlc_fit(self,clicked_points,xvector,yvector, pl_value, T=293): + ''' + Worm-like chain model fitting. + The function is the simple polynomial worm-like chain as proposed by C.Bustamante, J.F.Marko, E.D.Siggia + and S.Smith (Science. 1994 Sep 9;265(5178):1599-600.) + ''' + + '''clicked_points[0] = contact point (calculated or hand-clicked) + clicked_points[1] and [2] are edges of chunk''' + + #STEP 1: Prepare the vectors to apply the fit. + + #indexes of the selected chunk + first_index=min(clicked_points[1].index, clicked_points[2].index) + last_index=max(clicked_points[1].index, clicked_points[2].index) + + #getting the chunk and reverting it + xchunk,ychunk=xvector[first_index:last_index],yvector[first_index:last_index] + xchunk.reverse() + ychunk.reverse() + #put contact point at zero and flip around the contact point (the fit wants a positive growth for extension and force) + xchunk_corr_up=[-(x-clicked_points[0].graph_coords[0]) for x in xchunk] + ychunk_corr_up=[-(y-clicked_points[0].graph_coords[1]) for y in ychunk] + #make them arrays + xchunk_corr_up=scipy.array(xchunk_corr_up) + ychunk_corr_up=scipy.array(ychunk_corr_up) + + + #STEP 2: actually do the fit + + #Find furthest point of chunk and add it a bit; the fit must converge + #from an excess! + xchunk_high=max(xchunk_corr_up) + xchunk_high+=(xchunk_high/10) + + #Here are the linearized start parameters for the WLC. + #[lambd=1/Lo , pii=1/P] + + p0=[(1/xchunk_high),(1/(3.5e-10))] + p0_plfix=[(1/xchunk_high)] + + def residuals(params,y,x,T): + ''' + Calculates the residuals of the fit + ''' + lambd, pii=params + + Kb=(1.38065e-23) + #T=293 + therm=Kb*T + + err = y-( (therm*pii/4) * (((1-(x*lambd))**-2) - 1 + (4*x*lambd)) ) + + return err + + def wlc_eval(x,params,pl_value,T): + ''' + Evaluates the WLC function + ''' + if not pl_value: + lambd, pii = params + else: + lambd = params + + if pl_value: + pii=1/pl_value + + Kb=(1.38065e-23) #boltzmann constant + #T=293 #temperature FIXME:should be user-modifiable! + therm=Kb*T #so we have thermal energy + + return ( (therm*pii/4.0) * (((1-(x*lambd))**-2.0) - 1 + (4.0*x*lambd)) ) + + def residuals_plfix(params, y, x, pii, T): + ''' + Calculates the residuals of the fit, if we have the persistent length from an external source + ''' + lambd=params + + Kb=(1.38065e-23) + therm=Kb*T + + err = y-( (therm*pii/4) * (((1-(x*lambd))**-2) - 1 + (4*x*lambd)) ) + + return err + + #make the fit! and obtain params + if pl_value: + plsq=scipy.optimize.leastsq(residuals_plfix, p0_plfix, args=(ychunk_corr_up,xchunk_corr_up,1/pl_value,T)) + else: + plsq=scipy.optimize.leastsq(residuals, p0, args=(ychunk_corr_up,xchunk_corr_up,T)) + + + #STEP 3: plotting the fit + + #obtain domain to plot the fit - from contact point to last_index plus 20 points + thule_index=last_index+10 + if thule_index > len(xvector): #for rare cases in which we fit something at the END of whole curve. + thule_index = len(xvector) + #reverse etc. the domain + xfit_chunk=xvector[clicked_points[0].index:thule_index] + xfit_chunk.reverse() + xfit_chunk_corr_up=[-(x-clicked_points[0].graph_coords[0]) for x in xfit_chunk] + xfit_chunk_corr_up=scipy.array(xfit_chunk_corr_up) + + #the fitted curve: reflip, re-uncorrect + yfit=wlc_eval(xfit_chunk_corr_up, plsq[0],pl_value,T) + yfit_down=[-y for y in yfit] + yfit_corr_down=[y+clicked_points[0].graph_coords[1] for y in yfit_down] + + #get out true fit paramers + fit_out=plsq[0] + try: + fit_out=[(1.0/x) for x in fit_out] + except TypeError: #if we fit only 1 parameter, we have a float and not a list in output. + fit_out=[(1.0/fit_out)] + + return fit_out, yfit_corr_down, xfit_chunk + + + def do_wlc(self,args): + ''' + WLC + (fit plugin) + Fits a worm-like chain entropic rise to a given chunk of the curve. + + First you have to click a contact point. + Then you have to click the two edges of the data you want to fit. + The function is the simple polynomial worm-like chain as proposed by + C.Bustamante, J.F.Marko, E.D.Siggia and S.Smith (Science. 1994 + Sep 9;265(5178):1599-600.) + + Arguments: + pl=[value] : Use a fixed persistent length for the fit. If pl is not given, + the fit will be a 2-variable + fit. DO NOT put spaces between 'pl', '=' and the value. + The value must be in meters. + Scientific notation like 0.35e-9 is fine. + + t=[value] : Use a user-defined temperature. The value must be in + kelvins; by default it is 293 K. + DO NOT put spaces between 't', '=' and the value. + + noauto : allows for clicking the contact point by + hand (otherwise it is automatically estimated) the first time. + If subsequent measurements are made, the same contact point + clicked is used + + reclick : redefines by hand the contact point, if noauto has been used before + but the user is unsatisfied of the previously choosen contact point. + --------- + Syntax: wlc [pl=(value)] [t=value] [noauto] + ''' + pl_value=None + T=self.config['temperature'] + for arg in args.split(): + #look for a persistent length argument. + if 'pl=' in arg: + pl_expression=arg.split('=') + pl_value=float(pl_expression[1]) #actual value + #look for a T argument. FIXME: spaces are not allowed between 'pl' and value + if ('t=' in arg[0:2]) or ('T=' in arg[0:2]): + t_expression=arg.split('=') + T=float(t_expression[1]) + + #use the currently displayed plot for the fit + displayed_plot=self._get_displayed_plot() + + #handle contact point arguments correctly + if 'reclick' in args.split(): + print 'Click contact point' + contact_point=self._measure_N_points(N=1, whatset=1)[0] + contact_point_index=contact_point.index + self.wlccontact_point=contact_point + self.wlccontact_index=contact_point.index + self.wlccurrent=self.current.path + elif 'noauto' in args.split(): + if self.wlccontact_index==None or self.wlccurrent != self.current.path: + print 'Click contact point' + contact_point=self._measure_N_points(N=1, whatset=1)[0] + contact_point_index=contact_point.index + self.wlccontact_point=contact_point + self.wlccontact_index=contact_point.index + self.wlccurrent=self.current.path + else: + contact_point=self.wlccontact_point + contact_point_index=self.wlccontact_index + else: + cindex=self.find_contact_point() + contact_point=ClickedPoint() + contact_point.absolute_coords=displayed_plot.vectors[1][0][cindex], displayed_plot.vectors[1][1][cindex] + contact_point.find_graph_coords(displayed_plot.vectors[1][0], displayed_plot.vectors[1][1]) + contact_point.is_marker=True + + print 'Click edges of chunk' + points=self._measure_N_points(N=2, whatset=1) + points=[contact_point]+points + params, yfit, xfit = self.wlc_fit(points, displayed_plot.vectors[1][0], displayed_plot.vectors[1][1],pl_value,T) + try: + params, yfit, xfit = self.wlc_fit(points, displayed_plot.vectors[1][0], displayed_plot.vectors[1][1],pl_value,T) + except: + print 'Fit not possible. Probably wrong interval -did you click two *different* points?' + return + + print 'Contour length: ',params[0]*(1.0e+9),' nm' + if len(params)==2: #if we did choose 2-value fit + print 'Persistent length: ',params[1]*(1.0e+9),' nm' + + #add the clicked points in the final PlotObject + clickvector_x, clickvector_y=[], [] + for item in points: + clickvector_x.append(item.graph_coords[0]) + clickvector_y.append(item.graph_coords[1]) + + #create a custom PlotObject to gracefully plot the fit along the curves + + fitplot=copy.deepcopy(displayed_plot) + fitplot.add_set(xfit,yfit) + fitplot.add_set(clickvector_x,clickvector_y) + + if fitplot.styles==[]: + fitplot.styles=[None,None,None,'scatter'] + else: + fitplot.styles+=[None,'scatter'] + + self._send_plot([fitplot]) + + def find_contact_point(self): + ''' + Finds the contact point on the curve. + + The current algorithm (thanks to Francesco Musiani, francesco.musiani@unibo.it and Massimo Sandal) is: + - take care of the PicoForce trigger bug - exclude retraction portions with too high standard deviation + - fit the second half of the retraction curve to a line + - if the fit is not almost horizontal, take a smaller chunk and repeat + - otherwise, we have something horizontal + - so take the average of horizontal points and use it as a baseline + + Then, start from the rise of the retraction curve and look at the first point below the + baseline. + + FIXME: should be moved, probably to generalvclamp.py + ''' + outplot=self.subtract_curves(1) + xret=outplot.vectors[1][0] + ydiff=outplot.vectors[1][1] + + xext=self.plots[0].vectors[0][0] + yext=self.plots[0].vectors[0][1] + xret2=self.plots[0].vectors[1][0] + yret=self.plots[0].vectors[1][1] + + #taking care of the picoforce trigger bug: we exclude portions of the curve that have too much + #standard deviation. yes, a lot of magic is here. + monster=True + monlength=len(xret)-int(len(xret)/20) + finalength=len(xret) + while monster: + monchunk=scipy.array(ydiff[monlength:finalength]) + if abs(scipy.stats.std(monchunk)) < 2e-10: + monster=False + else: #move away from the monster + monlength-=int(len(xret)/50) + finalength-=int(len(xret)/50) + + + #take half of the thing + endlength=int(len(xret)/2) + + ok=False + while not ok: + xchunk=yext[endlength:monlength] + ychunk=yext[endlength:monlength] + regr=scipy.stats.linregress(xchunk,ychunk)[0:2] + #we stop if we found an almost-horizontal fit or if we're going too short... + #FIXME: 0.1 and 6 here are "magic numbers" (although reasonable) + if (abs(regr[1]) > 0.1) and ( endlength < len(xret)-int(len(xret)/6) ) : + endlength+=10 + else: + ok=True + + ymean=scipy.mean(ychunk) #baseline + + index=0 + point = ymean+1 + + #find the first point below the calculated baseline + while point > ymean: + try: + point=yret[index] + index+=1 + except IndexError: + #The algorithm didn't find anything below the baseline! It should NEVER happen + index=0 + return index + + + + def find_contact_point2(self, debug=False): + ''' + TO BE DEVELOPED IN THE FUTURE + Finds the contact point on the curve. + + FIXME: should be moved, probably to generalvclamp.py + ''' + outplot=self.subtract_curves(1) + xret=outplot.vectors[1][0] + ydiff=outplot.vectors[1][1] + + raw_plot=self.current.curve.default_plots()[0] + + '''xext=self.plots[0].vectors[0][0] + yext=self.plots[0].vectors[0][1] + xret2=self.plots[0].vectors[1][0] + yret=self.plots[0].vectors[1][1] + ''' + xext=raw_plot.vectors[0][0] + yext=raw_plot.vectors[0][1] + xret2=raw_plot.vectors[1][0] + yret=raw_plot.vectors[1][1] + #taking care of the picoforce trigger bug: we exclude portions of the curve that have too much + #standard deviation. yes, a lot of magic is here. + + monlength=len(xext) + #take half of the thing + endlength=int(len(xext)/2) + + ok=False + xchunk=xext[endlength:monlength] + ychunk=yext[endlength:monlength] + regr=scipy.polyfit(xchunk,ychunk,1)[0:2] + ''' + while not ok: + xchunk=yext[endlength:monlength] + ychunk=yext[endlength:monlength] + print len(xchunk) + #regr=scipy.stats.linregress(xchunk,ychunk)[0:2] + regr=scipy.polyfit(xchunk,ychunk,1)[0:2] + #we stop if we found an almost-horizontal fit or if we're going too short... + #FIXME: 0.1 and 6 here are "magic numbers" (although reasonable) + if (abs(regr[1]) > 0.1) and ( endlength < len(xret)-int(len(xret)/6) ) : + endlength+=10 + else: + ok=True + ''' + + ''' + ymean=scipy.mean(ychunk) #baseline + + index=0 + point = ymean+1 + + #find the first point below the calculated baseline + while point > ymean: + try: + point=yret[index] + index+=1 + except IndexError: + #The algorithm didn't find anything below the baseline! It should NEVER happen + index=0 + ''' + #fit the contact line + n_contact=100 + x_contact_chunk=xret2[0:n_contact] + y_contact_chunk=yret[0:n_contact] + + regr_contact=scipy.polyfit(x_contact_chunk,y_contact_chunk,1)[0:2] + x_intercept=(regr_contact[1]-regr[1])/(regr[0]-regr_contact[0]) + y_intercept=(regr_contact[0]*x_intercept + regr_contact[1]) + + #now, exploit a ClickedPoint instance to calculate index... + dummy=ClickedPoint() + dummy.absolute_coords=(x_intercept,y_intercept) + dummy.find_graph_coords(xret2,yret) + + if debug: + return dummy.index, regr, regr_contact + else: + return dummy.index + + + def x_do_contact(self,args): + ''' + DEBUG COMMAND to be activated in the future + ''' + index,regr,regr_contact=self.find_contact_point2(debug=True) + print regr + print regr_contact + raw_plot=self.current.curve.default_plots()[0] + xret=raw_plot.vectors[0][0] + #nc_line=[(item*regr[0])+regr[1] for item in x_nc] + nc_line=scipy.polyval(regr,xret) + c_line=scipy.polyval(regr_contact,xret) + + + contact_plot=self.current.curve.default_plots()[0] + contact_plot.add_set(xret, nc_line) + contact_plot.add_set(xret, c_line) + contact_plot.styles=[None,None,None,None] + #contact_plot.styles.append(None) + contact_plot.destination=1 + self._send_plot([contact_plot]) + \ No newline at end of file diff --git a/flatfilts.py b/flatfilts.py new file mode 100755 index 0000000..27e526f --- /dev/null +++ b/flatfilts.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python + +''' +FLATFILTS + +Force spectroscopy curves filtering of flat curves +Licensed under the GNU LGPL version 2 + +Other plugin dependencies: +procplots.py (plot processing plugin) +''' +from libhooke import WX_GOOD +import wxversion +wxversion.select(WX_GOOD) + +import xml.dom.minidom + +import wx +import scipy +import numpy +from numpy import diff + +#import pickle + +import libpeakspot as lps +import libhookecurve as lhc + + +class flatfiltsCommands: + + def _plug_init(self): + #configurate convfilt variables + convfilt_configurator=ConvfiltConfig() + + #different OSes have different path conventions + if self.config['hookedir'][0]=='/': + slash='/' #a Unix or Unix-like system + else: + slash='\\' #it's a drive letter, we assume it's Windows + + self.convfilt_config=convfilt_configurator.load_config(self.config['hookedir']+slash+'convfilt.conf') + + def do_flatfilt(self,args): + ''' + FLATFILT + (flatfilts.py) + Filters out flat (featureless) curves of the current playlist, + creating a playlist containing only the curves with potential + features. + ------------ + Syntax: + flatfilt [min_npks min_deviation] + + min_npks = minmum number of points over the deviation + (default=4) + + min_deviation = minimum signal/noise ratio + (default=9) + + If called without arguments, it uses default values, that + should work most of the times. + ''' + median_filter=7 + min_npks=4 + min_deviation=9 + + args=args.split(' ') + if len(args) == 2: + min_npks=int(args[0]) + min_deviation=int(args[1]) + else: + pass + + print 'Processing playlist...' + notflat_list=[] + + c=0 + for item in self.current_list: + c+=1 + + try: + notflat=self.has_features(item, median_filter, min_npks, min_deviation) + print 'Curve',item.path, 'is',c,'of',len(self.current_list),': features are ',notflat + except: + notflat=False + print 'Curve',item.path, 'is',c,'of',len(self.current_list),': cannot be filtered. Probably unable to retrieve force data from corrupt file.' + + if notflat: + item.features=notflat + item.curve=None #empty the item object, to further avoid memory leak + notflat_list.append(item) + + if len(notflat_list)==0: + print 'Found nothing interesting. Check your playlist, could be a bug or criteria could be too much stringent' + return + else: + print 'Found ',len(notflat_list),' potentially interesting curves' + print 'Regenerating playlist...' + self.pointer=0 + self.current_list=notflat_list + self.current=self.current_list[self.pointer] + self.do_plot(0) + + def has_features(self,item,median_filter,min_npks,min_deviation): + ''' + decides if a curve is flat enough to be rejected from analysis: it sees if there + are at least min_npks points that are higher than min_deviation times the absolute value + of noise. + + Algorithm original idea by Francesco Musiani, with my tweaks and corrections. + ''' + retvalue=False + + item.identify(self.drivers) + #we assume the first is the plot with the force curve + #do the median to better resolve features from noise + flat_plot=self.plotmanip_median(item.curve.default_plots()[0], item, customvalue=median_filter) + flat_vects=flat_plot.vectors + item.curve.close_all() + #needed to avoid *big* memory leaks! + del item.curve + del item + + #absolute value of derivate + yretdiff=diff(flat_vects[1][1]) + yretdiff=[abs(value) for value in yretdiff] + #average of derivate values + diffmean=numpy.mean(yretdiff) + yretdiff.sort() + yretdiff.reverse() + c_pks=0 + for value in yretdiff: + if value/diffmean > min_deviation: + c_pks+=1 + else: + break + + if c_pks>=min_npks: + retvalue = c_pks + + del flat_plot, flat_vects, yretdiff + + return retvalue + + ################################################################ + #-----CONVFILT------------------------------------------------- + #-----Convolution-based peak recognition and filtering. + #Requires the libpeakspot.py library + + def has_peaks(self, plot, abs_devs): + ''' + Finds peak position in a force curve. + FIXME: should be moved in libpeakspot.py + ''' + xret=plot.vectors[1][0] + yret=plot.vectors[1][1] + #Calculate convolution. + convoluted=lps.conv_dx(yret, self.convfilt_config['convolution']) + + #surely cut everything before the contact point + cut_index=self.find_contact_point() + + #cut even more, before the blind window + start_x=xret[cut_index] + blind_index=0 + for value in xret[cut_index:]: + if abs((value) - (start_x)) > self.convfilt_config['blindwindow']*(10**-9): + break + blind_index+=1 + cut_index+=blind_index + + #do the dirty convolution-peak finding stuff + noise_level=lps.noise_absdev(convoluted[cut_index:], self.convfilt_config['positive'], self.convfilt_config['maxcut'], self.convfilt_config['stable']) + above=lps.abovenoise(convoluted,noise_level,cut_index,abs_devs) + peak_location,peak_size=lps.find_peaks(above) + + #take the maximum + for i in range(len(peak_location)): + peak=peak_location[i] + maxpk=min(yret[peak-10:peak+10]) + index_maxpk=yret[peak-10:peak+10].index(maxpk)+(peak-10) + peak_location[i]=index_maxpk + + return peak_location,peak_size + + + def exec_has_peaks(self,item,abs_devs): + ''' + encapsulates has_peaks for the purpose of correctly treating the curve objects in the convfilt loop, + to avoid memory leaks + ''' + item.identify(self.drivers) + #we assume the first is the plot with the force curve + plot=item.curve.default_plots()[0] + + if 'flatten' in self.config['plotmanips']: + #If flatten is present, use it for better recognition of peaks... + flatten=self._find_plotmanip('flatten') #extract flatten plot manipulator + plot=flatten(plot, item, customvalue=1) + + peak_location,peak_size=self.has_peaks(plot,abs_devs) + #close all open files + item.curve.close_all() + #needed to avoid *big* memory leaks! + del item.curve + del item + return peak_location, peak_size + + #------------------------ + #------commands---------- + #------------------------ + def do_peaks(self,args): + ''' + PEAKS + (flatfilts.py) + Test command for convolution filter / test. + ---- + Syntax: peaks [deviations] + absolute deviation = number of times the convolution signal is above the noise absolute deviation. + Default is 5. + ''' + if len(args)==0: + args=self.convfilt_config['mindeviation'] + + + + try: + abs_devs=float(args) + except: + pass + + defplots=self.current.curve.default_plots()[0] #we need the raw, uncorrected plots + + if 'flatten' in self.config['plotmanips']: + flatten=self._find_plotmanip('flatten') #extract flatten plot manipulator + defplots=flatten(defplots, self.current) + else: + print 'You have the flatten plot manipulator not loaded. Enabling it could give you better results.' + + peak_location,peak_size=self.has_peaks(defplots,abs_devs) + print 'Found '+str(len(peak_location))+' peaks.' + #print peak_location + + #if no peaks, we have nothing to plot. exit. + if len(peak_location)==0: + return + + #otherwise, we plot the peak locations. + xplotted_ret=self.plots[0].vectors[1][0] + yplotted_ret=self.plots[0].vectors[1][1] + xgood=[xplotted_ret[index] for index in peak_location] + ygood=[yplotted_ret[index] for index in peak_location] + + recplot=self._get_displayed_plot() + recplot.vectors.append([xgood,ygood]) + if recplot.styles==[]: + recplot.styles=[None,None,'scatter'] + else: + recplot.styles+=['scatter'] + + self._send_plot([recplot]) + + def do_convfilt(self,args): + ''' + CONVFILT + (flatfilts.py) + Filters out flat (featureless) curves of the current playlist, + creating a playlist containing only the curves with potential + features. + ------------ + Syntax: + convfilt [min_npks min_deviation] + + min_npks = minmum number of peaks + (default=4) + + min_deviation = minimum signal/noise ratio *in the convolution* + (default=4) + + If called without arguments, it uses default values. + ''' + + min_npks=self.convfilt_config['minpeaks'] + min_deviation=self.convfilt_config['mindeviation'] + + args=args.split(' ') + if len(args) == 2: + min_npks=int(args[0]) + min_deviation=int(args[1]) + else: + pass + + print 'Processing playlist...' + print '(Please wait)' + notflat_list=[] + + + c=0 + for item in self.current_list: + c+=1 + + try: + peak_location,peak_size=self.exec_has_peaks(item,min_deviation) + if len(peak_location)>=min_npks: + isok='+' + else: + isok='' + print 'Curve',item.path, 'is',c,'of',len(self.current_list),': found '+str(len(peak_location))+' peaks.'+isok + except: + peak_location,peak_size=[],[] + print 'Curve',item.path, 'is',c,'of',len(self.current_list),': cannot be filtered. Probably unable to retrieve force data from corrupt file.' + + if len(peak_location)>=min_npks: + item.peak_location=peak_location + item.peak_size=peak_size + item.curve=None #empty the item object, to further avoid memory leak + notflat_list.append(item) + + #Warn that no flattening had been done. + if not ('flatten' in self.config['plotmanips']): + print 'Flatten manipulator was not found. Processing was done without flattening.' + print 'Try to enable it in your configuration file for better results.' + + if len(notflat_list)==0: + print 'Found nothing interesting. Check your playlist, could be a bug or criteria could be too much stringent' + return + else: + print 'Found ',len(notflat_list),' potentially interesting curves' + print 'Regenerating playlist...' + self.pointer=0 + self.current_list=notflat_list + self.current=self.current_list[self.pointer] + self.do_plot(0) + + def do_convconf(self,args): + ''' + CONVCONFIG + (flatfilts.py) + Prints the current convfilt configuration variables. + ------ + Syntax: convconfig + ''' + print self.convfilt_config + + def do_setconv(self,args): + ''' + SETCONV + (flatfilts.py) + Sets the convfilt configuration variables + ------ + Syntax: setconv variable value + ''' + args=args.split() + try: + self.convfilt_config[args[0]]=eval(args[1]) + except NameError: + self.convfilt_config[args[0]]=args[1] + + +######################### +#HANDLING OF CONFIGURATION FILE +class ConvfiltConfig: + ''' + Handling of convfilt configuration file + + Mostly based on the simple-yet-useful examples of the Python Library Reference + about xml.dom.minidom + + FIXME: starting to look a mess, should require refactoring + ''' + + def __init__(self): + self.config={} + + + def load_config(self, filename): + myconfig=file(filename) + #the following 3 lines are needed to strip newlines. otherwise, since newlines + #are XML elements too, the parser would read them (and re-save them, multiplying + #newlines...) + #yes, I'm an XML n00b + the_file=myconfig.read() + the_file_lines=the_file.split('\n') + the_file=''.join(the_file_lines) + + self.config_tree=xml.dom.minidom.parseString(the_file) + + def getText(nodelist): + #take the text from a nodelist + #from Python Library Reference 13.7.2 + rc = '' + for node in nodelist: + if node.nodeType == node.TEXT_NODE: + rc += node.data + return rc + + def handleConfig(config): + noiseabsdev_elements=config.getElementsByTagName("noise_absdev") + convfilt_elements=config.getElementsByTagName("convfilt") + handleAbsdev(noiseabsdev_elements) + handleConvfilt(convfilt_elements) + + def handleAbsdev(noiseabsdev_elements): + for element in noiseabsdev_elements: + for attribute in element.attributes.keys(): + self.config[attribute]=element.getAttribute(attribute) + + def handleConvfilt(convfilt_elements): + for element in convfilt_elements: + for attribute in element.attributes.keys(): + self.config[attribute]=element.getAttribute(attribute) + + handleConfig(self.config_tree) + #making items in the dictionary machine-readable + for item in self.config.keys(): + try: + self.config[item]=eval(self.config[item]) + except NameError: #if it's an unreadable string, keep it as a string + pass + + return self.config \ No newline at end of file diff --git a/generalclamp.py b/generalclamp.py new file mode 100644 index 0000000..9390146 --- /dev/null +++ b/generalclamp.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python + +''' +GENERALCLAMP.py + +Plugin regarding general force clamp measurements +''' +from libhooke import WX_GOOD, ClickedPoint +import wxversion +wxversion.select(WX_GOOD) +from wx import PostEvent + +class generalclampCommands: + + + def do_showdefl(self,args): + ''' + SHOWDEFL + Shows the deflection plot for a force clamp curve. + Use 'close' to close the plot. + --- + Syntax: showdefl + ''' + if self.current.curve.experiment != 'clamp': + print 'This command makes no sense for a non-force clamp experiment!' + else: + self.current.vectors_to_plot(self.config['correct'],self.config['medfilt'],yclamp='defl') + plot_graph=self.list_of_events['plot_graph'] + wx.PostEvent(self.frame,plot_graph(current=self.current,xaxes=self.config['xaxes'],yaxes=self.config['yaxes'], destination=1)) + + def do_time(self,args): + ''' + TIME + Measure the time difference (in seconds) between two points + Implemented only for force clamp + ---- + Syntax: time + ''' + if self.current.curve.experiment == 'clamp': + print 'Click two points.' + points=self._measure_N_points(N=2) + time=abs(points[0].graph_coords[0]-points[1].graph_coords[0]) + print str(time)+' s' + else: + print 'This command makes no sense for a non-force clamp experiment.' + + def do_zpiezo(self,args): + ''' + ZPIEZO + Measure the zpiezo difference (in nm) between two points + Implemented only for force clamp + ---- + Syntax: zpiezo + ''' + if self.current.curve.experiment == 'clamp': + print 'Click two points.' + points=self._measure_N_points(N=2) + zpiezo=abs(points[0].graph_coords[1]-points[1].graph_coords[1]) + print str(zpiezo*(10**9))+' nm' + else: + print 'This command makes no sense for a non-force clamp experiment.' + + def do_defl(self,args): + ''' + DEFL + Measure the deflection difference (in nm) between two points + Implemented only for force clamp + NOTE: It makes sense only on the time VS defl plot; it is still not masked for the other plot... + ----- + Syntax: defl + ''' + if self.current.curve.experiment == 'clamp': + print 'Click two points.' + points=self._measure_N_points(N=2) + defl=abs(points[0].graph_coords[1]-points[1].graph_coords[1]) + print str(defl*(10**12))+' pN' + else: + print 'This command makes no sense for a non-force clamp experiment.' + + def do_step(self,args): + ''' + STEP + + Measures the length and time duration of a time-Z step + ----- + Syntax: step + ''' + if self.current.curve.experiment == 'clamp': + print 'Click three points in this fashion:' + print ' (0)-------(1)' + print ' |' + print ' |' + print ' (2)----------' + points=self._measure_N_points(N=3,whatset=0) + dz=abs(points[2].graph_coords[1]-points[1].graph_coords[1])*(10e+8) + dt=abs(points[1].graph_coords[0]-points[0].graph_coords[0]) + print 'dZ: ',dz,' nm' + print 'dT: ',dt,' s' + + else: + print 'This command makes no sense for a non-force clamp experiment.' \ No newline at end of file diff --git a/generalvclamp.py b/generalvclamp.py new file mode 100644 index 0000000..4a8cd66 --- /dev/null +++ b/generalvclamp.py @@ -0,0 +1,498 @@ +#!/usr/bin/env python + +''' +generalvclamp.py + +Plugin regarding general velocity clamp measurements +''' + +from libhooke import WX_GOOD, ClickedPoint +import wxversion +wxversion.select(WX_GOOD) +from wx import PostEvent +import numpy as np +import scipy as sp +import copy +import os.path +import time + +import warnings +warnings.simplefilter('ignore',np.RankWarning) + + +class generalvclampCommands: + + def _plug_init(self): + self.basecurrent=None + self.basepoints=None + self.autofile='' + + def do_distance(self,args): + ''' + DISTANCE + (generalvclamp.py) + Measure the distance (in nm) between two points. + For a standard experiment this is the delta X distance. + For a force clamp experiment this is the delta Y distance (actually becomes + an alias of zpiezo) + ----------------- + Syntax: distance + ''' + if self.current.curve.experiment == 'clamp': + print 'You wanted to use zpiezo perhaps?' + return + else: + dx,unitx,dy,unity=self._delta(set=1) + print str(dx*(10**9))+' nm' + + + def do_force(self,args): + ''' + FORCE + (generalvclamp.py) + Measure the force difference (in pN) between two points + --------------- + Syntax: force + ''' + if self.current.curve.experiment == 'clamp': + print 'This command makes no sense for a force clamp experiment.' + return + dx,unitx,dy,unity=self._delta(set=1) + print str(dy*(10**12))+' pN' + + + def do_forcebase(self,args): + ''' + FORCEBASE + (generalvclamp.py) + Measures the difference in force (in pN) between a point and a baseline + took as the average between two points. + + The baseline is fixed once for a given curve and different force measurements, + unless the user wants it to be recalculated + ------------ + Syntax: forcebase [rebase] + rebase: Forces forcebase to ask again the baseline + max: Instead of asking for a point to measure, asks for two points and use + the maximum peak in between + ''' + rebase=False #if true=we select rebase + maxpoint=False #if true=we measure the maximum peak + + plot=self._get_displayed_plot() + whatset=1 #fixme: for all sets + if 'rebase' in args or (self.basecurrent != self.current.path): + rebase=True + if 'max' in args: + maxpoint=True + + if rebase: + print 'Select baseline' + self.basepoints=self._measure_N_points(N=2, whatset=whatset) + self.basecurrent=self.current.path + + if maxpoint: + print 'Select two points' + points=self._measure_N_points(N=2, whatset=whatset) + boundpoints=[points[0].index, points[1].index] + boundpoints.sort() + try: + y=min(plot.vectors[whatset][1][boundpoints[0]:boundpoints[1]]) + except ValueError: + print 'Chosen interval not valid. Try picking it again. Did you pick the same point as begin and end of interval?' + else: + print 'Select point to measure' + points=self._measure_N_points(N=1, whatset=whatset) + #whatplot=points[0].dest + y=points[0].graph_coords[1] + + #fixme: code duplication + boundaries=[self.basepoints[0].index, self.basepoints[1].index] + boundaries.sort() + to_average=plot.vectors[whatset][1][boundaries[0]:boundaries[1]] #y points to average + + avg=np.mean(to_average) + forcebase=abs(y-avg) + print str(forcebase*(10**12))+' pN' + + + def plotmanip_flatten(self, plot, current, customvalue=False): + ''' + Subtracts a polynomial fit to the non-contact part of the curve, as to flatten it. + the best polynomial fit is chosen among polynomials of degree 1 to n, where n is + given by the configuration file or by the customvalue. + + customvalue= int (>0) --> starts the function even if config says no (default=False) + ''' + + #not a smfs curve... + if current.curve.experiment != 'smfs': + return plot + + #only one set is present... + if len(self.plots[0].vectors) != 2: + return plot + + #config is not flatten, and customvalue flag is false too + if (not self.config['flatten']) and (not customvalue): + return plot + + max_exponent=12 + delta_contact=0 + + if customvalue: + max_cycles=customvalue + else: + max_cycles=self.config['flatten'] #Using > 1 usually doesn't help and can give artefacts. However, it could be useful too. + + contact_index=self.find_contact_point() + valn=[[] for item in range(max_exponent)] + yrn=[0.0 for item in range(max_exponent)] + errn=[0.0 for item in range(max_exponent)] + + for i in range(int(max_cycles)): + x_ext=plot.vectors[0][0][contact_index+delta_contact:] + y_ext=plot.vectors[0][1][contact_index+delta_contact:] + x_ret=plot.vectors[1][0][contact_index+delta_contact:] + y_ret=plot.vectors[1][1][contact_index+delta_contact:] + for exponent in range(max_exponent): + try: + valn[exponent]=sp.polyfit(x_ext,y_ext,exponent) + yrn[exponent]=sp.polyval(valn[exponent],x_ret) + errn[exponent]=sp.sqrt(sum((yrn[exponent]-y_ext)**2)/float(len(y_ext))) + except TypeError: + print 'Cannot flatten!' + return plot + + best_exponent=errn.index(min(errn)) + + #extension + ycorr_ext=y_ext-yrn[best_exponent]+y_ext[0] #noncontact part + yjoin_ext=np.array(plot.vectors[0][1][0:contact_index+delta_contact]) #contact part + #retraction + ycorr_ret=y_ret-yrn[best_exponent]+y_ext[0] #noncontact part + yjoin_ret=np.array(plot.vectors[1][1][0:contact_index+delta_contact]) #contact part + + ycorr_ext=np.concatenate((yjoin_ext, ycorr_ext)) + ycorr_ret=np.concatenate((yjoin_ret, ycorr_ret)) + + plot.vectors[0][1]=list(ycorr_ext) + plot.vectors[1][1]=list(ycorr_ret) + + return plot + + #---SLOPE--- + def do_slope(self,args): + ''' + SLOPE + (generalvclamp.py) + Measures the slope of a delimited chunk on the return trace. + The chunk can be delimited either by two manual clicks, or have + a fixed width, given as an argument. + --------------- + Syntax: slope [width] + The facultative [width] parameter specifies how many + points will be considered for the fit. If [width] is + specified, only one click will be required. + (c) Marco Brucale, Massimo Sandal 2008 + ''' + + # Reads the facultative width argument + try: + fitspan=int(args) + except: + fitspan=0 + + # Decides between the two forms of user input, as per (args) + if fitspan == 0: + # Gets the Xs of two clicked points as indexes on the current curve vector + print 'Click twice to delimit chunk' + clickedpoints=[] + points=self._measure_N_points(N=2,whatset=1) + clickedpoints=[points[0].index,points[1].index] + clickedpoints.sort() + else: + print 'Click once on the leftmost point of the chunk (i.e.usually the peak)' + clickedpoints=[] + points=self._measure_N_points(N=1,whatset=1) + clickedpoints=[points[0].index-fitspan,points[0].index] + + # Calls the function linefit_between + parameters=[0,0,[],[]] + parameters=self.linefit_between(clickedpoints[0],clickedpoints[1]) + + # Outputs the relevant slope parameter + print 'Slope:' + print str(parameters[0]) + + # Makes a vector with the fitted parameters and sends it to the GUI + xtoplot=parameters[2] + ytoplot=[] + x=0 + for x in xtoplot: + ytoplot.append((x*parameters[0])+parameters[1]) + + clickvector_x, clickvector_y=[], [] + for item in points: + clickvector_x.append(item.graph_coords[0]) + clickvector_y.append(item.graph_coords[1]) + + lineplot=self._get_displayed_plot(0) #get topmost displayed plot + + lineplot.add_set(xtoplot,ytoplot) + lineplot.add_set(clickvector_x, clickvector_y) + + if lineplot.styles==[]: + lineplot.styles=[None,None,None,'scatter'] + else: + lineplot.styles+=[None,'scatter'] + + self._send_plot([lineplot]) + + def linefit_between(self,index1,index2): + ''' + Creates two vectors (xtofit,ytofit) slicing out from the + current return trace a portion delimited by the two indexes + given as arguments. + Then does a least squares linear fit on that slice. + Finally returns [0]=the slope, [1]=the intercept of the + fitted 1st grade polynomial, and [2,3]=the actual (x,y) vectors + used for the fit. + (c) Marco Brucale, Massimo Sandal 2008 + ''' + # Translates the indexes into two vectors containing the x,y data to fit + xtofit=self.plots[0].vectors[1][0][index1:index2] + ytofit=self.plots[0].vectors[1][1][index1:index2] + + # Does the actual linear fitting (simple least squares with numpy.polyfit) + linefit=[] + linefit=np.polyfit(xtofit,ytofit,1) + + return (linefit[0],linefit[1],xtofit,ytofit) + +#==================== +#AUTOMATIC ANALYSES +#==================== + def do_autopeak(self,args): + ''' + AUTOPEAK + (generalvclamp.py) + Automatically performs a number of analyses on the peaks of the given curve. + Currently it automatically: + - fits peaks with WLC function + - measures peak maximum forces with a baseline + - measures slope in proximity of peak maximum + Requires flatten plotmanipulator , fit.py plugin , flatfilts.py plugin with convfilt + + Syntax: + autopeak [rebase] [pl=value] [t=value] [noauto] [reclick] + + rebase : Re-asks baseline interval + + pl=[value] : Use a fixed persistent length for the fit. If pl is not given, + the fit will be a 2-variable + fit. DO NOT put spaces between 'pl', '=' and the value. + The value must be in meters. + Scientific notation like 0.35e-9 is fine. + + t=[value] : Use a user-defined temperature. The value must be in + kelvins; by default it is 293 K. + DO NOT put spaces between 't', '=' and the value. + + noauto : allows for clicking the contact point by + hand (otherwise it is automatically estimated) the first time. + If subsequent measurements are made, the same contact point + clicked the first time is used + + reclick : redefines by hand the contact point, if noauto has been used before + but the user is unsatisfied of the previously choosen contact point. + + When you first issue the command, it will ask for the filename. If you are giving the filename + of an existing file, autopeak will resume it and append measurements to it. If you are giving + a new filename, it will create the file and append to it until you close Hooke. + + Useful variables (to set with SET command): + + temperature= temperature of the system for wlc fit (in K) + auto_fit_points = number of points to fit before the peak maximum, for wlc + auto_slope_span = number of points on which measure the slope, for slope + ''' + + pl_value=None + T=self.config['temperature'] + + fit_points=int(self.config['auto_fit_points']) + slope_span=int(self.config['auto_slope_span']) + + delta_force=10 + rebase=False #if true=we select rebase + + #Pick up plot + displayed_plot=self._get_displayed_plot(0) + + if self.current.curve.experiment != 'smfs' or len(displayed_plot.vectors) < 2: + print 'Cannot work on this curve.' + return + + #Look for custom persistent length / custom temperature + for arg in args.split(): + #look for a persistent length argument. + if 'pl=' in arg: + pl_expression=arg.split('=') + pl_value=float(pl_expression[1]) #actual value + #look for a T argument. FIXME: spaces are not allowed between 'pl' and value + if ('t=' in arg[0:2]) or ('T=' in arg[0:2]): + t_expression=arg.split('=') + T=float(t_expression[1]) + + #Handle contact point arguments + #FIXME: code duplication + if 'reclick' in args.split(): + print 'Click contact point' + contact_point=self._measure_N_points(N=1, whatset=1)[0] + contact_point_index=contact_point.index + self.wlccontact_point=contact_point + self.wlccontact_index=contact_point.index + self.wlccurrent=self.current.path + elif 'noauto' in args.split(): + if self.wlccontact_index==None or self.wlccurrent != self.current.path: + print 'Click contact point' + contact_point=self._measure_N_points(N=1, whatset=1)[0] + contact_point_index=contact_point.index + self.wlccontact_point=contact_point + self.wlccontact_index=contact_point.index + self.wlccurrent=self.current.path + else: + contact_point=self.wlccontact_point + contact_point_index=self.wlccontact_index + else: + #Automatically find contact point + cindex=self.find_contact_point() + contact_point=ClickedPoint() + contact_point.absolute_coords=displayed_plot.vectors[1][0][cindex], displayed_plot.vectors[1][1][cindex] + contact_point.find_graph_coords(displayed_plot.vectors[1][0], displayed_plot.vectors[1][1]) + contact_point.is_marker=True + + + #Pick up force baseline + whatset=1 #fixme: for all sets + if 'rebase' in args or (self.basecurrent != self.current.path): + rebase=True + if rebase: + print 'Select baseline' + self.basepoints=self._measure_N_points(N=2, whatset=whatset) + self.basecurrent=self.current.path + boundaries=[self.basepoints[0].index, self.basepoints[1].index] + boundaries.sort() + to_average=displayed_plot.vectors[1][1][boundaries[0]:boundaries[1]] #y points to average + avg=np.mean(to_average) + + #Find peaks. + defplot=self.current.curve.default_plots()[0] + flatten=self._find_plotmanip('flatten') #Extract flatten plotmanip + defplot=flatten(defplot, self.current, customvalue=1) #Flatten curve before feeding it to has_peaks + peak_location,peak_size=self.has_peaks(defplot, self.convfilt_config['mindeviation']) + + #Create a new plot to send + fitplot=copy.deepcopy(displayed_plot) + + #Initialize data vectors + c_lengths=[] + p_lengths=[] + forces=[] + slopes=[] + + #Cycle between peaks and do analysis. + for peak in peak_location: + #Do WLC fits. + #FIXME: clean wlc fitting, to avoid this clickedpoint hell + #-create a clicked point for the peak point + peak_point=ClickedPoint() + peak_point.absolute_coords=displayed_plot.vectors[1][0][peak], displayed_plot.vectors[1][1][peak] + peak_point.find_graph_coords(displayed_plot.vectors[1][0], displayed_plot.vectors[1][1]) + #-create a clicked point for the other fit point + other_fit_point=ClickedPoint() + other_fit_point.absolute_coords=displayed_plot.vectors[1][0][peak-fit_points], displayed_plot.vectors[1][1][peak-fit_points] + other_fit_point.find_graph_coords(displayed_plot.vectors[1][0], displayed_plot.vectors[1][1]) + #do the fit + points=[contact_point, peak_point, other_fit_point] + params, yfit, xfit = self.wlc_fit(points, displayed_plot.vectors[1][0], displayed_plot.vectors[1][1],pl_value,T) + #save wlc values (nm) + c_lengths.append(params[0]*(1.0e+9)) + if len(params)==2: #if we did choose 2-value fit + p_lengths.append(params[1]*(1.0e+9)) + else: + p_lengths.append(pl_value) + #Add WLC fit lines to plot + fitplot.add_set(xfit,yfit) + if len(fitplot.styles)==0: + fitplot.styles=[] + else: + fitplot.styles.append(None) + + #Measure forces + delta_to_measure=displayed_plot.vectors[1][1][peak-delta_force:peak+delta_force] + y=min(delta_to_measure) + #save force values (pN) + forces.append(abs(y-avg)*(1.0e+12)) + + #Measure slopes + slope=self.linefit_between(peak-slope_span,peak)[0] + slopes.append(slope) + + #Show wlc fits and peak locations + self._send_plot([fitplot]) + self.do_peaks('') + + #Ask the user what peaks to ignore from analysis. + print 'Peaks to ignore (0,1...n from contact point,return to take all)' + print 'N to discard measurement' + exclude_raw=raw_input('Input:') + if exclude_raw=='N': + print 'Discarded.' + return + if not exclude_raw=='': + exclude=exclude_raw.split(',') + try: + exclude=[int(item) for item in exclude] + for i in exclude: + c_lengths[i]=None + p_lengths[i]=None + forces[i]=None + slopes[i]=None + except: + print 'Bad input, taking all...' + #Clean data vectors from ignored peaks + c_lengths=[item for item in c_lengths if item != None] + p_lengths=[item for item in p_lengths if item != None] + forces=[item for item in forces if item != None] + slopes=[item for item in slopes if item != None] + print 'contour (nm)',c_lengths + print 'p (nm)',p_lengths + print 'forces (pN)',forces + print 'slopes (N/m)',slopes + + #Save file info + if self.autofile=='': + self.autofile=raw_input('Filename? (return to ignore) ') + if self.autofile=='': + print 'Not saved.' + return + + if not os.path.exists(self.autofile): + f=open(self.autofile,'w+') + f.write('Analysis started '+time.asctime()+'\n') + f.write('----------------------------------------\n') + f.write('; Contour length (nm) ; Persistence length (nm) ; Max.Force (pN) ; Slope (N/m) \n') + f.close() + + print 'Saving...' + f=open(self.autofile,'a+') + + f.write(self.current.path+'\n') + for i in range(len(c_lengths)): + f.write(' ; '+str(c_lengths[i])+' ; '+str(p_lengths[i])+' ; '+str(forces[i])+' ; '+str(slopes[i])+'\n') + + f.close() + self.do_note('autopeak') + \ No newline at end of file diff --git a/hemingclamp.py b/hemingclamp.py new file mode 100755 index 0000000..bc2bc1d --- /dev/null +++ b/hemingclamp.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python + +''' +libhemingclamp.py + +Library for interpreting Hemingway force spectroscopy files. + +Copyright (C) 2006 Massimo Sandal (University of Bologna, Italy) + +This program is released under the GNU General Public License version 2. +''' +__version__='2007_02_15_devel' + +__changelog__=''' +2007_02_15: fixed time counter with my counter +2007_02_07: Initial implementation +''' +import string +import libhookecurve as lhc + +def hemingclamp_magic(filepath): + ''' + we define our magic heuristic for HemingClamp files + ''' + myfile=file(filepath) + headerlines=myfile.readlines()[0:3] + if headerlines[0][0:10]=='#Hemingway' and headerlines[1][0:19]=='#Experiment: FClamp': + return True + else: + return False + +class DataChunk(list): + '''Dummy class to provide ext and ret methods to the data list. + In this case ext and self can be equal. + ''' + + def ext(self): + return self + + def ret(self): + return self + +class hemingclampDriver(lhc.Driver): + + def __init__(self, filename): + + self.filedata = open(filename,'r') + self.data = self.filedata.readlines()[6:] + self.filedata.close() + + self.filetype = 'hemingclamp' + self.experiment = 'clamp' + + self.filename=filename + + def __del__(self): + self.filedata.close() + + def is_me(self): + ''' + we define our magic heuristic for HemingClamp files + ''' + myfile=file(self.filename) + headerlines=myfile.readlines()[0:3] + myfile.close() + if headerlines[0][0:10]=='#Hemingway' and headerlines[1][0:19]=='#Experiment: FClamp': + return True + else: + return False + + def _getdata_all(self): + time = [] + zpiezo = [] + defl = [] + + for i in self.data: + temp = string.split(i) + #time.append(float(temp[0])*(1.0e-3)) + zpiezo.append(float(temp[2])*(1.0e-9)) + defl.append(float(temp[3])*(1.0e-9)) + + #we rebuild the time counter assuming 1 point = 1 millisecond + c=0.0 + for z in zpiezo: + time.append(c) + c+=(1.0e-3) + + return time,zpiezo,defl + + def time(self): + return DataChunk(self._getdata_all()[0]) + + def zpiezo(self): + return DataChunk(self._getdata_all()[1]) + + def deflection(self): + return DataChunk(self._getdata_all()[2]) + + def close_all(self): + ''' + Explicitly closes all files + ''' + self.filedata.close() + + def default_plots(self): + main_plot=lhc.PlotObject() + defl_plot=lhc.PlotObject() + + time=self.time() + zpiezo=self.zpiezo() + deflection=self.deflection() + + main_plot.vectors=[[time,zpiezo]] + main_plot.units=['seconds','meters'] + main_plot.destination=0 + main_plot.title=self.filename + + defl_plot.vectors=[[time,deflection]] + defl_plot.units=['seconds','Newtons'] + defl_plot.destination=1 + + return [main_plot, defl_plot] + \ No newline at end of file diff --git a/hooke.conf b/hooke.conf new file mode 100755 index 0000000..2fc002d --- /dev/null +++ b/hooke.conf @@ -0,0 +1,51 @@ + + + + + + + + + your_own_directory + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/hooke.jpg b/hooke.jpg new file mode 100755 index 0000000000000000000000000000000000000000..fca9e8882aedb8f866e5e2e2a11abb14d1c8f24e GIT binary patch literal 116617 zcmcF~cUY6nyJi#+ktWi+(mT>Sh*AYXgb+GP6+)5TL8XV@rAvoUB4Fqckrul2-g|G7 zP{Zc?opYVC*Y5tayOUfglf2K&yfgEZ`?=?C`feHU__d0<3gF(o`v4Z~18_G7c!mA; ze^>qy@_#1*d-dHf0NKNP=zCo__gDb;$?oBh-Mi}qFarShaPQ;Xy9fA>g8z`<5$*#V zJi`0`{Ne3mz&$*`{d>6g?&IR%JjBBz!+yfSdw~BCKtT3{oPzS%BNibYD%J!xVMXe9 zAHL*Oj~vs`vWvXr5Or})YhR($(>E}*NE#iVm^^u<uKHkHBbANB8gH+{e9-{{a7=Zebh5#d|>Z1pguVGYTO}7DXM_4{X9O-&uS)W*2#7>5}&~ zBq1>=|Kx5KK!k%8PKHAUPyispvE?%pwg9}b<=)E5$};|ctHNK)h*btBFif%J=a)3` z2=14bB5LwWf{kL7z&do3`7A7)H7uO_5fQoAx7@b>Dkq)E@-pFn68FDo;eS`fEZ}F< z)_(_>zXKe?a=BS}u7f%-gm(Zu^Ci?BpnMK}2e`iW@5NjNor5rcPij23s$ZPDg}Ar; z0O|^AR(hZRO|E&poep*f+kARo{%!O1mS%AOF@#dxI)R>^5_*&f*!Gt#Ut+iekU5dR z_ohT!oGhBmQD4Uyy)~NY4STic^Q#-x&yFpzd`I zd|t98Kw#fRkNBPO;ZaJb_L)BEm1AO*tZTjsT47a(=>VJ(fuKa+H@%T{Yz& z?_T{bdECB``Z&Rmd}N8)BYGzPhEt4kaY;0H&+A;(FL1kMY@7S2`$ID7+03JJy$?^5 z?gh8%UfpogsayC4jmGKS0ZM+!2bKIq(8Iw+|$mg52)_ddFz(!`$5*w;TFf#DV#ZzdSK!2?fK*-)le2 zT~L^il^eS*xhl!J13b6`IA4SA0L-MpLG`!gcYwIhpZrSD+UMG&?wB1+*_J{(Vh%HL z2k7Yr59H)6knMGE9|~=m&3jx&NglEL zL|t{8zkFyvqdzwI{l|a{jMY|b>Vr<~i&brhHvv)`cGxkaML4Rp2X;qpEV34%QdhZ^81Nu8KVzUl2ocJ6ZBkVo&R+=R{rho#pd2RFQzJB z;^m8iP%B|~fXkjJ5S~|aQESd5bMRjM#|D`UREoRO&c5r79dc&SV_9l>@Iga)etFq2 z*Ee2O`Jo0Wjf9xc5IWJ<>;41EB)ZsTZoc!@BWDLY3f+!>jlgI4*crP8;6W$H7ZTS& zvxtieeEk1!v)i$}bZ`f-zXPl-Gcf(D7enD-IP#=hL$!6u&Q7QyD?sRqQL9|tf?t^= zFt)-g#S-jj;}9S5@L3%F=F`Y^E0=006friKw)&5tpp3(gXKCw1RpF#{dC>87vE;2k zb|Pt?<7~CPG(O6HHPAPlN@FOXp|$M0ux0XIqKI~d59*ma1Ioi)f}+gxZumUv1fk!C zULD5XaC4BQHdHf5ubgPuOGPIJpgyaUHZZa&`I+YoW)bMrDOtCWDgjknITa#hvSgCx zXJZkl`jCd)u;ckADT;YT%g=SA#*4bAR#Lxh37c0~Dt@$WTWXF53B|u>#XLamr>Lw&FEcnzVqux^=0+>{;Y;2_)a` zo0}g8d&C9tXqNx=;UQ*=oG9XfR{yH+7iXG*vYG%A|YaVnV^z?~# zbrIu&oOnl6?lo-%M%dGD;r zjPuwl-0OfI**INi+RvXh`ooj|jqJB8x;S79Sy|cYL4K?j`A3b&$^wqD1*fB<czrlH}=dhD)~&H_=rUhyNeBDCV_s8arY`>K#z$QvK?%*Hen>qmrV! zc`1_3oS&9z(>r}(#s^1aI0oRV0vag>)8gg_6vQg#y1k|h8;hH)uCtGRAm4r^CvB!D zy7A}-t5?ToJSQAl(33f6(lOy@xG(=ha`m!}pg1Itkfgnalz~5%JR`B1S@;b@^x1fL z72Ib5fp4u|U0m^8=3e}dEqh!gh{%TBcMA4+k@$;^P_v#m(FXw?>hyuUkbt~iEl<)P>UgTlwLNniH6&FxOTA(@oHGg z#`pnXCHJmEb~lRd07Sp-aOEXl%ASLCYg~y8lr7gksrJeqt8AnUfeYF10B$CeP#)ga zo8&5*L!IOZ`FLL9CHe;2I!h|tfI1ti#E(z?Q*|c!#%jc_O`ulsWJJt;I}uh|`qFJJ zBRk4VPRUNZgAHp@ZO&`+GtQCM@+{HK8X$c|k>W}g^dr~s@34=jVwD&1#0w&`GptlY zHce8Le@?utK59tU%Dw-*;qkqXm$ZWJWqa03&@RMg2kNL84{^1=GGv}Ejp&h9WVQq` zjC8)BT3yY3tLR{WTAHyzi~d$qve_1S@S8EhIgI0Ryt<+Ei;&i*N$XR5iY*~A@2HUH zz!TI7THZyKvrVXmUb?_xiIBsCTNs1aL3W_Z`}oNy_d|b;!_mh;)@?svO*u7^ewWHA zIGK-ou<%jNkv1ckn1?PCP*PG?%4n@JsiAG9tD@f*qrRHas2UMz?WY`bfHz<;Kl`t62Px!ea>m$-QksiXNhrPsq4gO!l)n27v1EnG6O zbp`@rW_=N}s>sw~cO2G}JVepBUbtUQEy-&BZ@~tUU+J}RBJ!`NBsP~85<5{%+{+2H z&(DO82kF@@zkT+^9NqzPj?T6J*`cyHtpB+stI{fA`Pv44y~;lix*_{oY#XtRD+?(H z7-%hiQZs&lcG!2pm(r&=$j^k=FYR{ivURlF1P$ORLsv$!$KA-pt~TZR$}~>(PP1gj zlbKl6KlUD5;53|>I{^NW&F(`N9YVT00MLgZ@M5Iwx)Hj5Ht1AH4emASH`%sQI*dtG zGra^dKoVbGtlPtp5Xe-?+}83CIQ^!s`ddjx`ydgmbez{%TNj-a4QSCa&yTGrq0%X9 zKT)C)GV|W|)1h%k0;O!h1v=`?B;5`2ePB&wboQw7>~H!Bq!jkWx_XR>-%(Gu@u{DM zLlG^G*&SdLOm~!f7`c^?Ux+x~)xYv}EtaV9b?W$Nv+SnJ~Glz|3b*7F2neuv44?-{#Or6W>y!81TqEPz3iIq-af3?>!Ww*MMOMSIH7Z){x$aP0`rw)5FD?M8NeqM$Esm$8x zL{@88miC^eIOpG|i+W4upgGT1ny{js25BP{Q+E)s*2^&?wmvv^qO116^7b2_d~}lK zvVZ(~;}xf(Fv^~7!BUBoH)`+8cIG6>{+3sE$Df7J&iTfUc_~YZd<@_0zz=I-L?x5R0!7YbtdF}UeyUK)tafpU{}L}P+q-iNPyZ&9UtfN}^i74J z_GN5k9yiQ24* zn_z5F7<|B6{M2wZ>adq?MjmMmUP({r>#1&G$v^v@f7~MYDC#$+zh_44FlK*mA+6c0`ow{V4 zqhE~G7dldrsGX<;u^Q!i$%L3yYA<-d3}}(vcD>4)T#|WH)xFIPH#BAZRQH!o_B`H; zA`NKMITP}u{O5hU4oP7aH>;2@n~9w8fZ2_+wzH%>W9`+71rFoUj3r(&0M3Wy252km+Kp{LTZYp56ek#RX;p0VoBWa)#q|5Dr z$syALZVEQ|!VCEFC5}Gr4sfbD%yD$_yOFHm#P;Qdy)b3so=-}>Cx1ce)p!BLJK6z|7Pi^qciiH)Lw}1b#}P%P*!vIjY0;G zkJ6)~M9=ga>wquuzYF^)9ndI19dfz{T=MBXMp2zmU%Px6a>U{ne+P(|8p(~RWY)1; zXrT&e$hj!?d=%x_L_*6NG>MY$5Xt<3*Vp5BU7@75WCc{^VS%M4Zccjad$Q#VS)Ja{164jS zLqhA`6c(>wx!Ic@!r!A}SXAE^6j+vYBf@WbQ@BnnX7h=_S_K=c(l8I_NbSeC>6rWi z=W+GYK`Oo=A!WqG7|vnko`aQ+uGtQb3|)k_XR!d=M%s#M(!o{yS9n?6tpMG*xsSqI z_uR1Qq<#NEWoN{r(#Sq2OW19S{Z z#QBgC^h;JOv7V$h%eAyS@Psw@@13UREo>b@qihG<${pHMKSYM^98U2vRmG~baU1no z`q4M~^p5{y>;K&aAC9EdftLOQd@Pzvwl3TO5dg=Q!HN7H^*?)vd#n+e1ThFz+E!!owgMNVa$ER)L&-s>#DJ8tL$|lL z6F|^aAjV~&Mx0xpN?birqC(RzCECAq;%a&;U&?4oVFtPmPAP?!Ldp)7iKt<`oTis5 z*OjyBF_7FBiWyAg_1SCzz)hd%W_oLyus{`1cgwTdj7nFAE{cYwf6VT2wX<+{ zv5yrh+qPQEpgZOKe!DVTHw8IcCb!t7r(fUevBPV42DXFs4^Mw->*lOkJyd?qf+J06 z2*4}uU}@9Q-M{+$GZZPR61KS%WuPX+qgDofJR0|;9<7?6IALX|C7R-X}@CitV1QChei1wzonIm!ZLKSu^NxL zeA6#Jh0n3>OACme@7L7)Jb5CQZSuM*F8mBO_Pokhon~D{{h1uidPuLO9*A?!>3lh> zpU~%!xRz+@CftJyjs`@(3dm@$tmK8fU1NlK)il~Ngp27eQX>`~<8!r$^!R&Qxw-tk zny3E&hl~=M2>jOV`l_6$UZ}peU&5V)t828`Z_Ya_VtDNKgq9UNyH$F9&}U-#T2dT! zNsLFS$!uh7f4`1uNY5WZHf@)0XwZWaSFk~ruE#{}S6*bAql)To#5#L#R>a?sf@z9Xp zxBRMN!e`kvQ~lmLi9D~gp2DAJ85B#EiOjEBxGkr0!k3sK`S;$p4PsF`7~Kbg+O_5^ zTSixg5MC9IQv8pvQ7P2}TWU1}BKUM4BEQgyj>IPgMD)H-pq@eA1`sqW_c8ujWwP4T zXc3FW3ENy(*}8~r`;$n*a`Nlp>QA%_gVF6~4;cpmNT_Y-Wdzv{TdkI8jWqrh<&Efk z?XbkM01fmE+&kBC~7YbvsavxD>tQL?RMW z|7G=1;Mb>m<_*dEs2aFr1rP}~6oPgEsOB{-)e;_d*hOcHD3MHL)a zU~M%n*WdqbV~~{_yS6(}$;8vFXLPkj-)H50o|BdNi7IZa^X&*hT%3?X+|jHGIm~mT zZo!KQbuuhu+&})+JQR!Rp<%=A#O57AU*ha`VI8O>p*ZT8YLhZVy0w0xrq0Qx^{bTK zb0yW6ffefc=0WZmlJmKTmpfjUd3S(|=_SmfAQlE1X3lAKFM!MSFKtyJB#VCJ@VGrMTb zS$)LPrJIkUK6#w@ZVHkN*o-tOhWGzMz42pZ-Rx;9JPeacV~GnL3KH&`R8j(G_`(A} z24Tb{?f~^scYvi+cy5<%?{k{~o;Mwj+!4(2gdvt3b_3EWj_~A{9BeU(!}j+alVYhR=5*bzpdv+98$ssZD6V~H z`Z27VGdrx!9QX#m)_^Ui(E(vI1C5fml*TC*(4gF1MFdQ+s!O7H=#xP6`Mi;t@)0TL zNJ}4`2*a4$4R5Mk;%^0(BfJ`2*Qh!2aX8$_=#~BWUM`@k$@-YZtSQ($RN$9ZU+(tcUNA2 zu3;G7bz9uk7*ehmKFVq?7*tFzY04gEz2VJ5)ZUbU^x2l|ab89Ay!eZ&Jxlf(%0zu# z!yF`Mf592zy;mix{AEReYdAEc{bbU$UP9UE@rfcz39t|GW6dTvDw;D&Oq`z06>dq2 zWBY2GaH_T?$SoJsc6*g_FkSSyyZ7%{|C=4ktZYQiNnK!-!LCx|Y4l|ZLyt^y!V|&F zYROzJqtudWYxs@I;KssYqOFVMZ7g*0_s5Lga{1Kx=^}r1w-0SwE)1y*(LKFHCn}S@ z?|{vl*>g*Zf^X$BPA2Mk_b;wlktViTl->5CCd%JUjcZa|>Tl-c@a_TK(O<_&#(@Gz z>9f7|Q28|^74w#I`GtRgx!72y7_eV#~DO>T+8&_QLJulb{4+s`V>4)WJN^F zeM~m;v^`vQMPVPUE!VIRL>xE+8nreyL&Vx3@>BM|H2qYq5b}pdEt=p1DnEXrrq6>Jk6@$ql)s3QanpVyWdSzF-!{! zxk`a3chF;P_S0cToAZ{pkl69;rj~)kCG#R>zO2msQGLJ1%J+CD=$*pjr8?sUg`Q{fLeZjrL#_ z>QV(&r@|vwsrP&S&xhU@JFpE`ap9x5ntA_9qy20h>OzlzpK81CCE{2KF=VF}L)5Wh z(^PqMYFZj3tmwO4gEph0T{TC&F|)7@pT+FD#ju}jT{i!~`VW>~_PMvige7KdVfrkM z;~W^exOd<-gxjF4S|h8HUJKcfr*KTS;tbU#Z^Kv`CwS|juTr|nYzyYrGQc#{iZtY* zyH^7S4mM>Of9Ks#@BFiI=es(hgk@zH+qc3_F0tUK(`Ax&@_}#ITd~EzN)y zbWvv|ajsq!pk52tC-TNLengS5gucrs5=|c$9<>5Z{)QPaMZ2W{X;9S)ymXO!=aKol z)BbHq>-^H$zBF;HD_cly{Vcvp9W=NrwvkCidW1#UOf!W6?qv|m0gtUVGA2>Z@WFzK z3W(L=^`8@0A?JuFRd#mr2;Wx6g6Ga@h}EUjw5PNbQ734W#jtWhsjrj{!(WS%>q_TD zpBuIo%PU)4&yqAX>J{<&q*DXPDMsP?SeUl)KqG3s?D|$^XYQsH8hMH~Us%*XnnEP$ zrnFkh#JayLTy-h%&J7Jts8Us$=sr2OZcToBE!ZXBY%2AM>|h^}f?IkA@PcEkbs zlrRZkZe{nbCCCDpxgVB@Z(qtJx@D2($RUV^_oP|eqy`lP=JC-ALds(DW>tUaJoC4! z_BJ7k_z!8b?MP#(g_jZ!2hd_Hot7!UBUiC#rE(%?kd(Jdfw`+P5U8YN4~5|-JtL0s z8zHHUuEZU+k_~UFjxNX5P8}@i&qkV5AoC-u5^aY;#)S#hF>bksD&6^wI#lg}h)=#= zNh1MIu7VxFF5_aJ&*x8`r2wTC1O?KYA1M~4rbW0(-_!p38?6DNxa_xBWL^f2&ZK&L zfgk@KEjYVB5TpF*RIC4kO~2l(DN{O+$Oph6%hs0E!Laz^>)GSHobAu^(iH8uvD}Y< zI{6OHF%f8uit`8O-ws0ja@4}DXPCVH3?$gNNS$!TMPE1zpHiuTcVD~k_EX~nXwIaIRUL^|{CNZrV_XpH%Yv~0{weBaDLLPjcfxbFarBx_n6KQ0O@j9v`J zt(A?3oP%}WSjgZ$@Z{YAL>^d2BluPoJR2GL7=#1Z?a0zZYBWA1UsapV()}oyqfBFI z1)4>Jkfoif!W8PDnbrFC9c;F^)<=~SD0127YA_|M{W_Ip2)R>HIsP4$MTP#iA7i2}rBpEn@?tk*$ zlx7u0{keJ1s#EN}Bn*9SI8zMK06&0-7$jEfPC(OSlL)xyVZGCf6l)}k8;39JzD5yt zOD<4t89Kb0MSlL?KSAkiE$p9CdKpBaP9w{2UgOG0K_)h;)(uOfmOO(=`@+#}=pWLL z`31aWk6NnMhFVTqYWoc41A0_UFfl!sbl(DUAJ@p={}wP3ue5~xAv`(+1W~dSR&6w8 z*dWZfZs)W@E6Y@9nW{228i7MfKqa$E5Hxj)c=U?#V zkp?!bwUd%C?v|0Q=bbUO+2dT>J_vJU%E_qWO)MiNnf-yAk_3{B?2uG;xBAY(nisWa zj~Kxj%gw*B+?krl3MbmgE7!Q-#UFJexjgyBEhD$TB5aDc5jm{qX3uMaopVyrC1q=_ zcrm#`AK`xW+*PA6n%J8-el`jhT!g7VEqi($2h5Wz1#EkS&H*+{s3OW!0fLb zyk=^4lQ}BG*$_0ISW%<}fsoGUom7#`DMs=8DDw|U!!(}0L?cUj=^mBj1)Y(1Fhhl~ z(yj9cAfHE+4J`97<{~X(+u@K>o5wW%K2ieppD}+F#WR`?nrZ<{;bzwCVD)} zBy!}%%qjf4g|bn~$3&?-l+GWOi=a^QhNe96Ecgg`bW?^z-)1b+d%%#Hbtbu?slEFe`|f2`cq`ODoccuNK|AOpRSIQ z(kU)gi4QGSO<8&vqTuCn{IBf5(6`&ID;z_v-C?Q?_Y=~bP8IW|&)`t4mK}x-|0T^G zpuTe1LO>31zR~zL2%LLiwq)KvUMgp(T&}ozkioAn{q{Ffs4P8>X?jK{^U;KIZvpKi zHon|V(kd`{Zl`>iHAcGcA8wQ88>fF|wb#;ayY2um%*I*Vkb2O(`4yH~WG;Dw1>Vli z7Rc`a@_U%bW#%>}raJ(ZUa@rt;Qp^{C?BY!gcOPNaC+JNN9Y;taUeg-&~!18v^q=q z2PVNFpNBkNK~Gm>dmrW~t0>dYm*zNXxfvgv>S-q>RHDtDy#x_8oM<4*d=3+O%GS(# z)$^g*G?m8WFADc_I7$LFWXmcAdC*o*-uwzbN&4x zTF5K8ihqOratUVYx#^_XP9<5fiNYt=av=72|N6yCN8aaXp;|*K2$XB}YrQfXeht36D6uik2TB}Zc_+KwT#r&cwyWa7=ow^~oSnKV~OlmWY$ zU>e{%^2)uv88Im7Nz_Y&zPMBM>UqEwg~A@4-iWC#pLb}y`f zt`(2U=~(jp1Z#YVvp4@btqgsrwBzR zywE!U=2HlTatNPu%j=myxSBafOEhO$kGbl%n_N!i;>w|HO7M1luXg$KS=C0%i0Pkv zl;P=`oV5l@si|BJn!fTBer=s^a3Zc@Gnk-#5?0u?)WwsqVN0;7hH|NB(q38nXeL8& zl+H(>ow#r?e=kqolx*tL+{Bet4z)EuxnI8Wae*18B3r)1%$OwVtjwZKmIIsmk`+0p zlo^AUJx^IE)*_;$FNnsBk(YDgA;NL7z7!w-wEeUh6iqVP3--;P9Qrw4B#3(qV>YqE|8w9vbb`7a#6|AI&5_y={t zkZ(rNOXc&{_ek7LtS8@a6t2jtRYHan8u3;hX!p!5M6o+*eN0+HyNWF}<@<4d-^wvp z=zN8=LoU{|=Eh3Je`zQoXqSD-JA0JuQc5E!;fd38ea+GiZjydNCYM4GtK@YEX;VT+ zF~2F_FdZgj`!wZzb`|x!bNvo5y(M@_kFx_NyBN-q4SEDOW#mQIoPNqWzhNZ{yb&;; zc{1>rQ=vq*WEd|L5fY3Ayl}qw+4en&&AAE$b*_HpHoh)2xXJV4{MB;@=wjsfMZo5K zbZ`w<;B+nh;Kw#lekmipBlOQv~;_qvTi7m06OEt@0) z?*JRT*PGR@T;H$LFN6*=nC8BHx2&yaMqm!YEi$Pl zlsBYTc&9o(Ej4|LQVd*r)B@XO^1=FjRB_KZ(#B;fvuqib9AtqLyl<4M#R8_q!g)_k z@~{+FK|;PyzjB9{YKfeb?f}2GV7FwN zJw^W7YfL_WSoBe`G4ruz+Oax_&<`~dA6(Clvy33P%9v9)`Gy8%5CJJuk+XU8hd$vK z)%}$lElvUiS;N*@vMR6rf;<)GE(<>RS+Js%ZwoC)bWtZ*go~2tu%CN}yP;2?Tr$xO zU-USfHs^x*#IUq~s#eFFA`(j+B$#OQ=}c+sKkQ9IY42tp9gn)~t_BD^2kDZ;r}Q$U z^v+4VIk_l%RKdq=T^!C#bcb4U}z>|5y zf819}z51{<2sas-4lCtsgH863p2~|E=Ifh)w5=~Of88_6nYnqNqxfs%Le)89!Eem3 z&RBwS&*TCP>x~`G6|$`-z&l2#TCC=^Sj$=|>$vZP5CMXcclO781-D~(4R3^6f=nyV z6$j^OL|yBnFD#Sm#OyCdw&k*ZkF9nmD$LC!iZXuK?#U10@lR;$E88k`&T%mBc-5yj z@3tEnTi7D}IDaFSZ#`DL+=pD8rtpuZoPJZOm0t?Y&Hxred-5=Wr=WzbG%spHG@*Ji zsm;>grn;&L1A+WS&Y%P5(FWz`z%_JS2^Kg>kT~z`cRb@)&W642FIpjI?&DGYd(fPw z{!U>mZ0;%+nQLvH+k~H^ki9lV(0XL+E#@(%^n$9jZtLT#R9tPS%DB@3SD||l1GgK)?lzyV{nxmQwqc*F1mr15jX z&`_P#WVmiRa!!NX8g-Lt?6QnjPBEEE@H6PK6#7)+XUOoAtewsu`Zrdk5t&V1 zOSYuI%sLXh^80YE`#dpdY}tK@$+~jGvC+4OzQQz5_HlXIX+;vK$IM;mrb)GUS2uZ(d4f5RLV5oHJsCy`V+9YA>tAEHs?ptgk-K{3Yq*w-1_)M zKC2Ced>=r)FtbH``AJnuHfU;9vD`gcRq|C`CeJ-~0qX0ZG{K#psC!r>vX8vj8H?|Z z2HhCl*gL{r`!?2;5>yneR{fdiu&RdlQU*A|qR>(q(-{$vNuf!8*jI(blIzk=Ajppo zbO(3@;vQRX?&opWhM$} zF4UOwY|(?qRpv_0=#wzhl6CqJoZKXL)ot}l8HS_&tKFJ{ALowlxO~@Z1yr}2b+2~3 zLDEG`PY>1o^KV&Ym#JL#R>Gr#3J-g&uUViuUl%R1-k-PAzBh?{{2ihg(uWuo>W(}c zWVr+UL1IC!~bDw`A8Lh+57r&u# z&(5DPXvH_o?3bCpepfz?p!TtY=CeSdEG({2#KVYO58y2s@dL04S}fYWaYzqrG!Bv) zay^c*=j4GxOCus6(DL6*CQ}dIAX|Eo`l18I1QJ=7efaOXS><1YJs4mGIZz>kPK*b8 zILWk51VYR&pmo9@CAdb1zt+$n=h}(vO5zRa{X-_$`%Wx?DX^`{P+l8l9*zXbtRGol z{F6D2%X#1Sxs?lkK<(>IEp2MjaCYZ@`Dxm0u(s4kMnqKt?J>1DfrPPtNd3x7Z7_W=O9pCft>YA zpxHK-L4-z~Hvai;xWAuXT=LkeCb6#O?i# zPW1dQ_sz*x8_QX1Z+tRFzmC8AY~B4*Rpa0ye68Z4qn$zj2lq}r3CHz4dMQA3lRK0N)yP&PFE$guj|daMS; zzUhxkn`%p1JGRoQR6~vcy%G!HW`|GZ@X5Qgn0iWIy&Xx^XY|yZr`tF0;V;FhoIDyF z_Q*`R?q60a*Mh$WHlHqAyTZCE`RPibs7uvht+w^!sge@wlZEsR5{_v%*?HUSQHe;& zIV$*X`Hmzl=xfb`(22^7ems6+JiEG*mWvlZ#R<9gn4W6+G0KWxV+8hZi^AVD75K_n z2Z-T2fmiEP#7r5Kj88b(Ro^LLkO*V?=T_0Z45j1WO4rb!zD}dfD|&a!D!DtrLD3P3 zr|{W(N@Z$>>7&~8fzXCz5r^o4hBA`L{GrjeQSyLiuv)t~Ytb(T$b{AbVVZ&j|ENQ8 zzTSGU-ZF%H0ms zcBT3$lyt8}tW$>d?W4t?=Y_oG{HaLpb+u`#Ju_T4yPuDdT_{=6)N;}3i6eo8%9t^} zjw_=eLb9t?0INp=L&eue&Y3cddV%*pxv(if+sK3u6!n&*r#U)(*y_RU2 z;GIH47RQdOi7}F@bA0gugsCKmv|F)>YgK+IBcIzHtwFBy#CXB*4v>;ppD=W(R_YO5 zEp+7DMe%V%o1wZm+=y16iai~<5lI8nZ#ZHG z6hg!z;hYOqS3bno3Kk6Y@vdUIztt&?g;WNdYH{Z|8y~Q6BCD)i>&7@^*rXZE1lfBG zH8Io#*(JS}H7SatHfMT(c4KXgcermtUcA7yQ6+l(ie86|Zpo|BlyzcMZpbvZGdQI2 z4iGBC8k-y6K%Ok*Ums4b9FWMW8`4Uss7{q6;I`+yGyh3ggLHF}rVHXd(iaQP&*4;) z{nK|I)N5%p38x!ZftSXBjUr;eI+99C+9CfuFO)7`nX{*=7be;j`q#cj_w&`Xj;iw| zN|{`;`K6ekTl=c~=Am0sKy1p6h-+B_HgvKCF&d!G}vEc(W_ZWkft#EkDZI;)UrUwkpt|FKi7j@pZw!#M`##%4Or;Vmf z=Tu-!IDf@E8#2#K2ov7P98Q7M$310No`0_8Mg0#zf0>x&hL zRXrF;1r0s0(4!B%66x#?e&kwV-YR!0B}d&8qLx}9TOMygq7%oj!S2h4B39EK_L|no znk6q-@ECe|Y9xb@s!|5)U%4g@KvLDMLlAa@0^85rIiicxZH4w1Q` zdOvuWcmsKO3oJTka@lnKXO8$F6ZQpR>9P7!Gv2@a!V7tzi^-4o!`#;MzeVo$Dyy-^ zZ1mzPQSvUcafKVE#A^2wZOJW3&Rq>+c0T?c1$kAa$VI#eej)HB+ zArHch1M^1?H=c;W+>HLnkxJ(w#am#LT7dQP>Ef_0w@!wDVz?dgh2|ciLzkWMAgesl zMH#u;<%m47p1SUW>?bCUc`-;3y$dzf$$nzR=*qV8l~CFqK=Z2!ATa0(2=u@Gx8ozi|3PJY7;ygh zyl2? zYzc7aQd;_!r3|Uv+~TmOF1)T0%=fApZ=fmXRVi{lg|@D7pwa$0t*>PtM6w_mdi6Gx z&Q_J26;vJg(*PsjRi^44z|$18OXdOjgW_cWF1+`UzX2%zZIG~f5}))FJlDYpXYZ9u zT0+Qd-i$8oSwE#QJ3o$`0Z z{T1PlJm^arsIIROnQ^qXdgA~=@UlTMF|O_#Mve+4{OHac&cUwc11bJCRR4viDkBv= zb=BHZR8QUdV9aC?d9GwXb#Y?Wo0ZBU<#_4NYMyBoapgaV@VRlnV%f~e9K7vV96K-5 z87J=5JwM`bCOe_2@v3^Gxv~n;D^2WSyVazu<`RhCGLRE8$uf21;6%m|t6$eE|8mN# z73WsIseQm(-ec8Bz_@hDWny`j=#s;=_0l7ehPO>8&e>>s88TU?T418=qV*}H!a=>j z&nR+z^pd5$E3i!(ZW2QIKDF@qr0wXm|4BcEkgzziZT)$ZgdlKJ-fAt@@8&ppDOq6G zFrY^yd+c;(ppr3|HF0p}CWs6~?iw?Ya!s{Syj0k0WzkF#xXa6bUJQ*I0khOL=UtDh zPO*LuOrKS6Sj+YDjf%DNG$8iyB0lLBU(@~}oxicwp`^4a{nU-}`CNjL)vI1>Kb&vV z`iA*d+L5C^87T4};sJI02rC(&W6F=t!D%h)3D+028WxrULtjJwk~GU0_&n|*(&jR< z|1%@4O#jSb$&n|cOVz4WL zM_p8xKl1Q3b|t`pwEJ~6H5>)=Ezn*TL^)8}KYzY0tN!STcdi7}(NC+o>p7M+SDNV` z7p=a*C*CNN^k|RV&XOTji@&FOp=6b>_0sQ8r-~U~)xFaGi+j$tW6O08gDsG)Rl*Ov zEE|#_e4OJalLhnv8thqcRIw@e=nS;|Oscub#=xxN-X2N@ zZtA;9((L>VvEpRk>8gs;C(&h6)F$)%GZB_KLry9Sm%E7Y74(m+k_Pr4dapg& z%OR(yaI^N3?lQXa;H$dZw~Pk=Fd23~R=F)j`m*&?ArlAfgc{*h@uZagshtGFg+)bw zcsIlC&d%($18{xPo5X4F4)8SGRro{lTly zX9zYHs@bhzMATelsrO_9$+QPJV`IY}G99L%{43uY{>`!7@;7qwkMG_BmFpf|KuAPQ zil&}8`~worm4;Q$q*w;s7350u+;TkQYvX()&^Bpf>O*YQb%p*b5oY)XS~RNV|4Erh&l7jx8Y&SzOwcqZ>a8d+>pseFw{r{|m9 z8h`dkVIT6NNi_2^rOLuzA5j!M3ztnPY@ZD0g*0G#iRxHF4I;`9Dst8nq^0t~DN(F) z$PEkEP1NqB6G{Ju0^llm^qAzd*qQhH(ba~z^`$gTX8MC!w{$ww*9g_uedy zU(b%!zwFOJa=2^o)h%a^H&2+uF^yQFH&*kn@`Gs3yQ8eg{4J;K^VvsTsrio9LHODQ z>$nwPxrm-qX-fk~WAL}f9}nqOJ-u8ID;Jp94%ks)E|GXZTe6-9c94-9umCY4Q8)qY zufp>fDKcWkillPcGI(~ZU7A?mdreq-t~;1N-3xZh3R09~z4F*uB;93DjLy~QQ^Cz! zI1|5JGjk?}m|H(JN;i_FY&2?2kr%*`r*X<*1M@wS+pcNSy7^}L18v(jzvy$U9w?bj zOb2Y1E*8ep@LJrM&OvYN^)3>vFYH~rT^FGXpXOU)Dpt>V-T4& zET-%I;F4F8`=E+Q5(ymTox^wCVy0|~9@XH&q3oJk?YHs;w#|d%S|9h`{QU#aLW|Z| z3EGBYd4*y1 zR+B5mh?-P9U(5qkMEV0hR+XSB$~M=(oEo?T=o@2*44%KRScocbK+sKu_B#b-4AeJX zo;YlX*GX{mYWAWWd>Kyo&>T8F1?l<8Q<=gYeJcYrIjOB*;NO5bR->7~k$$3>qesnQJdN9163MQbt)f2t_-h1pa=Jbx z{I{g8p%ut$Ag0;{bsNb&C_0{?gDN#I;F(0KK9c_}{%AN%O1e$r_Y`vwjRauA1sS_F zl#hBSVDg|bVVa;L!K%*RtGG6n%_2?N&7&Z%C68ie#5%DKmG5K!?eAA!`joiC09Tpj z#lt<{E6?UASGb9`SK6Ax#LaJysdAsh62y;0%*X*l&Wm*o$>qnoN8iY4ijBWLr)Y#W zltk-4OVWOEEc(>GlnTfJtyAPK2p8&mKGEI^q1`@TR|tSud{8q<)jKdrV;- zRaH^c{#F!UX|2qfX>*kYY~@n={P`cboWcGRWUrG!s17|V_!Y%y7g3T#Y8wbD7O^GG zk)JP$v@ZVJv*v&5L?>@-!mt*r>hW(%>O~!TL#6^h27dfgai%l5Zoci;O;KLJ-FKpHb`)X!GgnJ z!C`Rs;2zxF-Cc5c&iB=M&;Qf^+N!#yrhD&wueGlGJ{l~zY_z|#tP@IkARG(gMI%Mv z>ewKcAM*B38PG+?d~;m_Esq^Od8ysGyc^0|8*INQy0a}J)?0 zY%wRuCyJTZXj2%nXP|V61K8JSj)rtfwM+H0v#S<7hzmsP1YvLXn=Z+dVX`HbHuFsY z!-pqenzx=Mzy~VBNx`9ry{2*CWl2Hfj+@>Rbc<`aahuFy){)~w>&l+g-jnM#lt%G* zE}FPJAfM|=!{*N_WdMG5cq^a?Y~ty)hxpe$vLMxZi7?V~!4Rn^Ge+X^7x=C4El{*u zaa^wbYV?+!VO?k+Dm~e&;7^;Y8}ZWpFI`YI0I^NmRtM?NNJ;|+N+SN-ii%HmJUua< zc{4>0KPHn86UR8`9Sd`9IWTfAEIr)~rgrae{@UsB(Wv(&`mP35y(tv^EJl!5*I*_g zj`H`tz{KxJ;~0uGmX6r2@AauPJ%gvC*sIGxuZrlhXuVl2fMEMd#*RevnG)X4@-yDs za%_#&hE!{zu5NO>!xXE`1ZJAFGh{1=4AQA1Za={{_tewIq%$#TpRtMO9eu81#2{`r zZ*MisbX&nVC0U(9mlF~6-CNQV65D)|+ouj`V9ukf6E^Da@K&@F!4tLTKi-$Ji+**X&zQ+*+J+c|YqY}9J=#wc880sfBf=Jtrm4m7lO-el0JB-`M>JfT?QnPg128+)7+PRdDP2_9H8lq%o`PP_LRorZ1 zO0kRGOiZ7^mOopwzBz9?ye&iN$wqy7>=zcr(qQq=$+NCg?UsHDKoxQSrqaw8AuqLz z9pWOYEpS$VThdp3^V(dD_rO@Jp}bj>8#u3C{v#In*hep1sojCt*kD9PizslyYEBtVO)syw=p8(P~I(diXhU3InY! zOd;=l1>?}KAL~bL=@Vm!67s1&XY}Q1j0S7s7i>zTh8s-rt{52_Y6^o-WC{056C-Cr z(5KciI9bV7R%#8>pc<=;D@KgQ$yD=~?)yxMctqx)FQ&Gg30lLZGf_?VK47XsDx3vm zCN}fRI7QcIQ*R>GaDqw~{l zpZjJyY|;K6{4?7QJoK3th*e6)@cne_O#;_@Rp?O0$(u7|Rx{-sLnfVGT}2Qh=>%(< zG%%7hJTSepNx(67h}K$(V@+~BBolwEnTXQ5XDnaVkdFvUF@`*;vGc9DmZecC-)0Im zcc0W(gP>Wa60+H41goPc@OifQYZ}}R3p@*`_xtO)htX9QX!VZ6x5ssPvIXS^;~T0* zwC2$Ui(DnS&ZG;**KK)AHE}a6_J-O6OclvkL4&^=73E1wO7_*bo#&q&Y}rWM88sLt zWeA+hT)6haD?rPAf|!CV*Q+T8PIcY(VEjzdH&ZSnfKMM4o^;uupUX68BdLMhTfvTQ}u!DT>$+j_I+odd1#9q5B z>7L-J#serhNMZxafQ$I%B%RHjJIg}?B5HNR4z5EkZ-q z;Xna>tk_*)7uDTMt7T^92^VDXa6FtxZdX^PWYE=-0;nCJ%?pv?6 zMSal0F4_>?dfab?cx7S{Wno&0F3*pH~yk;cZlzTAb8-6Ou z$isNLZB?lB-~`8_reL&fngKUEa4hWILx&m)Ct&U5!j!%bnunn--An~cMUtnw8w=dH z4L5b>>k}y2_NWxZ7}z@F3ms559}%=sIlfYxLnT+Cj4znaTUQqg3SGsPAr;SyDyjJm zsGO4}ddGPAHPxKV)-dk~>W`25x0KDlRMw|TTJ+mrx$%S&=F(i*9^Nu(-WZCvj0BgK zOm#0Ml}kK)f%7=ovZkY$5d8^NanfY-BK@fUm>-<1$2g>~V8DX!olSlsl>}(*?IT(d zx;>t}QLWkv$q0o9{>S9ODKG@WL)vF9xvA0o8tRM7O|@lHg$KSq?rG}Q^OyznH+AqGL zI(iM@H2P_Z5k4H#n5vUw^0sq$wdqm5&Pv^uh)ui1LT zo>bC>Q3p`!;A6bggh0`6KMP58-9cvMV(zK_4$YW;3873pbqTnY(aDNb%dHRy(TWw= zCS=Y5oQL9V|H+~-@eh$wcQD$P!ZV;d0mb>!Nk1vf;~a-16&&m`M;3}6#NQ-gyd`*| ze%E09u$9Cw7W0a#fY9lDR!_F&lwzZeE;nh%gU5Xd`KdH^`*-X3tOT2V`JodQ;)45D zBOlT1q!O2i=#Sr)rDlVXlbw!7F8ACSButuCU|!w%o^!d0>JtQQAQkf(Ni7W+2~g?& z9ht9Gc~!rtx0HL4&BL!8n1MJSnBA%yxkw1d%5K>x%=Z#Cm>}pUD2bMJEHp-J!r4XJ z(F`S3|JfUgIX4L5aSE)&im>3c88X>Hfimzau*y4;oWX?qnMOpLh4a5_H_g#=In&mhUh8HnXqU=q?VDvzY)9$4HpoK zjGB$xumS+SC8uZYEoR^>YtrN1y+B<*iReMUk>&1BL$u_I=_RZOAqG!-mb5Ez+ot-z zAC+jMVYap(ayzXKv%Ql&8x3@rFvua$ugvfp+XO0)&o>;>e|TXo<(p^=KmCXFm}p6F zmbkP&%_CMHuXZ?!**_iK3uUCsY|YMX=adU5x`poqXO*#h^wE?Qk-3t|BRKMzlHuD+ z-Rl(FWz0lt$z<~ElAT>_=VLJ(9G~~p90aUkkM~`sBL}qo8FL|qu{Go$Uiucmjr!Py&tP?MPW*c+9!o`jg7@i&#E+}j z?0PbDiy8pkv!R9qp)K;1 z5=fr=VR6S%pM%dY2^zQNh>D+D(SO`(H7fmaCRsDM|L~IEwk2e^MQh57Xy1t*A}g{D z2Crav5nJdTHQrd+ zIG`8b_NGxNtj%S^PD}4Jv)>?bTS@-J&1A~B+sA3^6m?Gfmatn8iVbwd_52B_4ax1t zC$*Oivt_US9>f>BEt1cI$7PP7?ky1EjaTNV%*!n}?So43?o*{@?o@L0&~ev4pH+YpC_LSF||#90V&f*)lvoRn|-@d+lOuHXIkqY zVI{YvSkq_ zMbx$W;t;1{+V4T6YQv~8+X)s`*rkI1n6U@#cTh)?#S#j`%nMDtx(~p;E1+(W5#G77 zwQK94#n^DB2WMIK^Fl9JiG@QpXzBbmc5v)F1Bs83z)meKy32Y*8Q66cZG^xQVf2+$ zoQr>U3--8bRk(r0!(HDoa#I=gfAIdcCQN?n^P0*H(6NZkZC{^*W%Qv za#2?|f+_MSz74ckU%k?$C0<-kdB=aJ8BT&6HJ?z0>V*bvJ@oMWHb`+MZ15a{?ExAo zkF9Z-i%Oi^X4XX}r8^`vN#nxul4V|gE+;zc;c!)9(D;B?x(PBc%_8S?t(i~R@Y=CvT@}Pj$>Q9@$f8^RAj47wHXaOW^ z(zpSK@7wOdA6ui39;Vva=0IqkcMvZgH&6vh5FSkbX2^wDM6vX z9a!mmnZpyi-NA2eK|OfI_BBNj+>*8@z~LvtB*qI z6OuVBu*k;%wqJOU^i!yJZr;FT<9HPaXkIux{_+=6HPG+oV{J)7F>)w(@N=NwTjoYO zbDmXuEZeogbU`8Hy8KNhePnP>M!yuM-v>5@Re&ZE#<~6eUML~N-k0URNV}I%j5W@e zQbnnVmC)DIE}(I$wSS{F?H-`oGtx|ZQGNp!IJUg&e|Y`la%DZ|$Fm5meO{Z>c0GZ! zKOJWHpmEEUt>`V_GqkR&u`q6EhAj~Ed(Yqp@2yieFo+n^ype}4^u+zun=$5ElRoog}#Hv`Z{^y3nO8c7T4>2VJUyWD0<=JLA^?X-c&_4GHD1^095eMrCPf(S$8p zJ`yta_>p!*V*)D!7MqcZK4_?8DhPe;Z#%+3d(-|yV0tu7mCPh#8K+%AmXBqxgiLA_ls2Lt#JCId+tYpJEO0= z@mKn6jg@7V_YPT!T#AZP!;qc717`!{+UuN2TijUrz*ndaU}|?D6)Ajwo%+tN@glA5 z1Ii4RJgV$pc}twAnPV51n*-ZM0 z?QlPdHBI5|8G9;h1mCQ&um)?XAq-Pv8*$OFyKoZ}YrPzbR(aX>#yH%`sw{dEj<^Xy zT%(!|Ot!<~(=#vTRW0f^K)zlaacF=Hbj&MH8NBIYeMX5fW?9{Yw>z!j*w9RSvIA~0 z?MkkwZ{k}L`hhHVnL|2{P>NCxXEhMFW^1bXySG_prYtVYli*xJGQ1SUPKD8bUKQcl zQjQ#muP+lHj4G~vOWWH%{C;S?J$$xK!!#_^)ne4KQT~H# z`UmI~{&IV7(r$M;$*muPlyr0+`kc(mU|ch*v-Atc&Qaa#Xy?9gFl@%`d9AydHG0{p zecc^OwSA|e^+_@IqHbVqD6iHUwPsvb41)i-Fkj}of^yM! z<^4bGN_K&eGqo&*Xz5~Kw(C3o0sWw+pP5cdd8R`d6;T5R00L62HDJIbvozj##?)RK z-=6J7!iatKf~c8bE}=b0MBip3^aI><4Ok{%R~2DRed4>(u+m zoz7_SCJ=frK%B+ve8zr>c9hVh{c1kAGUC7;S5lvr%KWJo=U0t#7LQ(}=GZrL`TKB6 z@&Fn;;-!Pcp^VI>7**1)8He+Ai>J_oGw2~HL(2ZC<`zdyk9SK6bA?$VJPlTB`$`Zt8b45c60piSl2TRA*AzPs$rt+vV;$JlLqNlFxbGYLlFhfIhs%z@ z%$QFtgp;B7SKKVE43+m(56en#uj39WpC#P@Uu>)ME7xd;!z>`ntbHM5{=sm>BsTM_4{o)$A@KN?;926WLaUD1AB2G=-JneSHs!WCU zo%eQpRhhTGpFNTm*E3f%PFnpworF{NtHG5k2rYCbb{Oekq6Vc128z2EAAM$*YRd3M zWV&cwYGNusScr}g@fM4#A6*3*{GU)y+94Wwj5kLfdaPkoObCT{3kwvd9D z6A203Ii?RPy7CEDH8T`d*!joq0nSWG?nmPY_W&?;4G9%S<(~FngFwXKC!fS^%j;o+ zf#t5r;n%zii^z}~{c6?AOdq5B`sd&QFCme(#-@tmHYnU+R!j{*LPIn84xb=mx1PZ0 zjLg#<=*C}fm@M!jw1mv!nA^s=jaGkVEfKI$|JX*;fTNm*bVRooGGUsBKlC6=o&#zH z9q=nrhJZg`-7@3R&~g*Sfu$qvX1TB*3=wLuawuLlI=-soWt5t4*hQGLQmP90y$uMf zz`KY{J~Ls=eE{I7e6qjsz5cV){%UhyAsR92N*j0OQTDTfT}il#_hi@9G;wk@@w0|N zTp#gCk7VRP>Spm8wh}AhUUkH*@mK0A3Go#wD@oS8JNHJUj%?z!IoRw)Kh+&k!87Wh z0eErg)+WZiaEnxTYgKU~p~Th74Tl@(P=wr_p>?2h1d^}i>-+UByV}K9-uUtlp@YF@ zh6M|n^c>I-nXSK9j2^*(^=2A}7jvDr<6AzSg!8$20zuTPu>m}g9QD;I@!;uxx@l=T zra04k*;VgD&Gz9td;ZXBM(Q5iX}r`Yl(KGk71yTbqfv&XUM;-@#|>(gq|uZRZ9{B; zc64lT93=uV(Z1_~J|2?mnPormMNgXHg|k)>SI0EBd-me4%XKSy-P)z3jtk!iGe8h! z$NFl)b0~SP`d9x?1Ah)Q+mXi(25d5emTSOC(ix%Q4OaS$tTn7}Yg#9J z)~g@JA2p$CM$0h2RzPC4UgMLd2sNu}Kcprg)9%lUwqt&I-s=Ske*wyR!ttE_x<7Ms z`>qkFL@I2ZF_{2OZGSy(4uJLyGU5oaNIv)d2`dNryew{uP*66uy+2KV8fkmEe%KlN z+;<7vD_6S#C04J5kJva~#aZ-CycH>{pZ!C)X&EqSlKD+0wxLOXbxKYau~jh`BxEu2 z=uQfA`fYealN*O?5I|%`{FLJ%H@JnRD}MYnIwVkOlI#h!#VluyQG8D&Ob%hVevNTw z6O=~Vs^8fyWf32BWVSwK6deZ!?m@onFt#ku5nLhSU!p~_+;K=tMyy!4++Hi0mA-q^ zzG3+_oRz8xxqt33u;v3Nk(mX(NA;>f7m17)2Ct z<1Y`sSSv8JjQ6OC3f;_b*eo?I>Ob)CfU%5TqWP6R43-)UV7!ahv62YVRoyJ91~8#A zjFN}+tLu+R-=Jx`spz~Zkx1}`S-^Uz! zGMe4D++LL;R(hja=M)TTUTI;^+tga>YW%hMvrU|%HRq-t)XE?6SdwB71@F!z69+)) zD&={C`l+z0;;W8D7*S`k#L%`b9Icv-<_#@&lp4=Cee`x&RHrY=SR#el_IaPGbzoAk zW+bA~*>oqLd_`ucA*Cpma8NeB-KdwaK#CnoDshGCVRL%s-YPr?#G|3* z@wCVMg3)?pei5zL>5SaAUXNKC=az7fScH3K;qg5REs@rbQ>^qKQ15V-!9>iOP0*&+bnTuQrOY)$fmoF z$8~aDcp?n%`R!G9Y(bO%F=gTJoWdGKLZZ(H{?Z}uS6{jl-aOcIq7bN~QD}98H!IF) z1W({Xn83Utkx;eynyx3qk@Ny4E~rVs7SV(hKKt7D^~=nA3z_Y7CBYlRW--}JUU3;3|81?d?FK9opbG)7b=ypI|$3 z_$BA8Y(KUJziot0WbG=o*`pMKv)B;)_b&&OnWqLfOowEsmmOLjo^M%(8qG(CWgC|! zD0B7bK1WijOnO3THQ(xj9rkjWj>V8-^3Xul@i=sK1+*?YD{GR z(~|td%rBQ2tb;RKx}4fKaF}I8b-SKeuyN?%d#sTzM3;nSI`g=yQcBNkoZ-FKXcNAr zH-i2A>;B21{80t4xpY?OLsBy2k|=s?j-zA?jm8p>`WN_6jsvAOcJ%m0vx)j6V*LkV z9T@3*`@~~MX!6fApM>o!DF;1Yy3|PY27}ASkxR5{r7351egE{zEhTPb;fRe>q3ts2 zNb%~5FHvZ!_0GRIP4{c?_#_)KsCcwzW|KxP*S-L0zK@&7zSY+kH|h7-ku#-y^)mii zFbtHvn&#Ft!d;g9`GqY3Mx_hOeCPAq6Ho$Cw@qwmn>i&vDrCM*+e*IRb0FLhc>EbQ3#94cBn?a;%Rpin;@=v)_4;xw7guS){ZcMLPL z#$cyMT=F{G*IIx~gWlbGwBdc}b3jYSW>{hFIf0MM&xFkV~2LA8*5uC^2XdWyE*8mBmFl^N3RWHC5@1x1h))RBm*lDRKC0chS1wxfPSs~j z&0STnlcwvZPp(#JI15plCWuk)2_PSgZDqcpy2>ZTfXDUBFD=-@VlI+*OR0Ll9jn%E@g9GLzXdUn@ADw9%%;e+ z4NEC&{$wMwgQ1Y~CX`?|5@jqY-izg3V5))-3y1pvwyZ!s!XC{ePF5M9x{@97<1K&R zOX3FRwzne4aJCKd<^+3lg%#~K!IWJ<8iH>mt+!1B2X93Hp{mJOyuEv~*>Y}`zZcYi zXZfTy-Tnv+b-UNov(22a=JDK*p3Rf+a%x$WYA+PIP#9a2HMEabo7Zr@cC38ra48A&WZrarY|{5~ zHG=v@w{1eg-`Uz%haj(x@g;=~)xN%PPycoM__(q3)7H_*^J>sLLwJTIq(0%=?i+hs znY@Ge3nP%L{miqPnu74wBg2$*T zlm-;%01qqf22xkdka8zceo5{nV&oL23Mu_wrS!>c8#eoHM}U2&l;y8Axu$>N>^^To z9)ag8)|r8Mzi~6GDXJ?MVl|e!qV>Qt+D+nzvT@Leh@8%p#&_PoF^a>9tPjZ8PQzxm z6pU#npF$IOoD@3fE=Dg_{5G6*M)t>W;k~ZRc+VF-Ad#Gn!2>o7}ol4e7TQUnQ)u6-LWw$P2c@ z+YNOB&cz4H#$gZ0sO9UXGkY$5O63p`Xh}3Dfwi=PGeuMCTX7hTwsyZ zS$~fF?@sHZCNIwWNhe4zA?-F1Vl$(WaRz5K!?A^oD#oM+3HBxb+M)6SzMsr^XmoO< z7oL1++GK@=g)fPt_AVe`DO1R;rw6ET$GBh1_ECEn#YyH3FNH=hW)?OWM;N|2Don9~ z=>IuEM{M7a$)e|;Ns68pQv)c?v(Fjd7`9yIUi~NLADpYNBixFBd0X|Eg|7mv0US8D zU-%SULHMbM)P&>$3RI*}MM682@{y?;*f-&$^|6m0^*@uvky&(;#!8O>l+Y0E#SzQD z&y~F*c@ypXiGh^9<3_t0vKW-Mo40o$yjw}DjF#b8V2Z21PNhvso>8spOgPhk!q9YF zRBCxxzeJSO;DtBxt8Ph~V4_+lxoMp@hqO8Kpe8O{P&K<&y-7CGQ2oL`I8y_P#uIW@ zJF|MXp920*yrw|fA>lOES~{CjgXr1l+t{IpEt5C{FFmcqAX&qQjp#u2N<6Ml`9R)S zoLg}wU_Vtn{}IU8Hz9h7sO*ONL)EYRP*b}(F(wLhlpw%&3Jh}wqhU-d-ID7xSxTb- z-e}*`_7lla#5dj7#^Vbma0rC&%U?TGUI{5g*@IsRz(Y98W-{H4*-+eRy$(N z7cMqNnq@S+_hkr@;=^V>3HlxAyhf#=Yvlj!d_O1xT&a~C@$mhd;-|SpT?u*)c^{v= zyk{){g`Jk})u`Ao2esggc_LSkrvWNve;$hbCkmA8Q(L)+rJGM3%Hct+{xm-hkbhZa z5=~UevNnvidorCX)kfbTI}6R%k4)ZpgX7v6FDV}jow?MFG5B|)ecjqF+s@SAu)hhN zIF4^*r~lo-=pE(I^W(_%J=&mK!X%zNtHHb761z}P3LdBaV1KM#K=uaMX;FLt-NP(# zR}2kF@w-;)*V{95aabDD&mh(CTo@&(tJEbd{(dyDlvyMo@I(KzL2H0D1dZ;5^@W%@^uZ3Zw#-$WLhwP#UZ$g~nmTH~O5JGXab}1T;p;%K| zW9)`KSMluLhDlRq7@%Z+SnVeEA#?k4*Cf<6fo`?7_uy(Ar@d_QF=lWy{7|AX$WVwZ zl3?_Bess`wK}xq-YkMvtt%>fw`^05Z_T1)sv&NmW2rD{Vkt5NgHBjmF>OKf;Q4>f# zcKa013rgWwq1#=77}}690h-6Yq{XH~iRTx)kPl0_l5qD_7S=3x11R+;dU=W|kZ8mL zZni4S&OG~@Hb^IA*O?3V!^SmhOX}f zXWHqfPG}oSQ|xB*#996hk97i3N#=q33=f*Q0X>wqtET8Rm|{j)^{hsaH;ij$_b5n; zEW8CHZ)$Kxt#LAGz%Xl%e|VNuIm1WsX8u|HcQ-Os`YFcjQo7}ZaDu-NwaM(T9{W7+ zdTgZA89H3f9S^FG(ILixleDLgV8w$y`)f3=!j4dmQ!+6}3gwnpRTGwhtnycHefzkb z6)g**uf{@^Ca}@|`9&#x;a|TZ?C8KNAI`|gT)U^rMii+=ko8{3XVu^}b+a&?l*d0Zau>hZ@kiIF)L~^s( zf)#_qrl;2d*OH^+_Vktb16;g7KBDnf-ZfqpHu!VY1`{`aAxixMt>~&bDLt;1M0Zsn zLISL0`-oQN(2K@G~`r>6kfbSp0x5!*?)S^O>`b()P#bP>xx*ZZ{iMcGtHlQ zG29a>KO7&!r6t+O+iMLMHG5*C?yK7pC>OzT=V&LNUDPvPJKi>UVQa(z)*864myQ3v z<2|1%lK$c(ZFbQ*w+0a{Z`|}w5>EoWW%#(tc2>v73YNOVjn2W3g%%j^jZI@xJBA=4NBuA*`9oCJ9T{)UlkZb zqTS%x!}Hr~NG$UyWnd_%%bpA@d&pol2Jsln1@@l6#i(s6{2I zl>%T?nd3I~mcC)^C4Fc~PLOP40~+*5{(~vhHAYly2bJtCLZ}*U%EEXAS1D#m{p;B` zYo)kSxCx|5v-@iFbSlSCPl6dk#JyGH6Ldo7A9eaSW8>t!Wbx&!F3mn4!agi;tw->I zIg@TH&1>Qp`Y~YR7C9wFPuV0YoOT$1$Tb#cZqlm`0tXLJ5(k{!{aQSSn~q?T$?&Pj zDP4KC(=O73bUbEg!e%jDZ@9pR5Cx}SOn>80fSgFK?ppy23DbAeYboI2Dff()KsTACb7{8g}76i=2Kfja4`n1-@3h3o_(%LF$X!Ax;z$!cX4TkoY>+1gtB2Q=a)n;Wyy&SOqUhu08V zVMyb}un=^tDLdP{dZ8sTsN)?CkV+G|r zue?TnU9012`Dr*4Q8l4k+gI6pE9))?+6>1sPK~&b_mC?`h3hT|CV4&!AjD<{U*8;O zN}`wlgZtv@Tdv1oh&1hXl&@c!y1tBxvh0(w2gMlrYf=7&PQd9_487LHGHbQ_7;JAK zFCacY2#Rus`+4Ig>v~_BQIJzyV zl(Mbse7Qy${}F}6>iT;9BDxqsU_+1#j7~klQhC|Ns#_qe2@Jyo>FLqF$Cy=K`gW*qy%sG!8oqf*o3gqz5-;vFF{RJ%>T z{6*rirD%R;JUP@@vOCodwQdC}wYm@xW1cx@1~>a%%8KcRjupp0R3ps&QYiT=!B|Dq|(q32^vX{QBJU!i~+BbJ}d54O369_6Hl#Rp~xuu+nYPA$M=U(9marcm6nn%V9;u zryUbM|KM_;7<{jDbKi_VZw+XDg}t}67sEQRrAkU#-mcE*nDOZ-d}DC*`gZ&`1x%~& z*HR81JmB)gOhF~pj30VkE*3JvhbzTY`{Y}rd&jF0$EZD9HwH>1oYrEX^%F-gGU7C2 zZF;-)K0(U;0Bpw-tZbY|<(?pgcBb^LGE&b@;yc=}zrzYLH(3=>2@O};<;aIgv4yQf zz$t!YMeBeazO2|gt(*}a;@wCBi@Ey!bkRO2^xc`EjZ`n~s%d%Yl1g=^t_QYzTncgC zmokyY*ZH3YB~81d*}xsFWNt#>FzNlR1a-9ef!XU9cNN~>n(yH2oK(X)j~BaU-6P&d z3~s~=Q;mle)p;BDA#)Q>?ej{9UZb-!V#xeb0dsuX*bFYHVN#4trRk3`+!*>yClk-C z>6#xeC|;2poVew`I%@+)hfzS#q|I5)JyMW)OSW?+(h{HR;w^i9k&>}$QncAyvpY?r zyCvV|U~Wm4eR1d>&ca`o&+j`@z6o?GQuop9$o-m<%xQ zA4_}1#RN^5nq}1n$_t>`!5j&r3*0j*quz{;Kh<-Fb_Za50e6%|1qcP)Y12{yvSSUX zXt}k;agfL@6y@jywi<8JvRikxj-xRU=&dL(ZY$fbekLbd z0yEmL{J863A9eVJoMITf+f)j$ppNKV#2<@T5j2Bi{ zzU)OsBqO;8U1-XS!apiVuGxHtgJ0Khj2f>JTeSKwrzWqsxH?i8?Y>*}P5d~b=)L@* z+Ii<=wr0XIj7(Da6cR0;dfzhCk|eeLcwt198e0a2Zr+S5x^tPnerK#+3`0$-duSIy z8jm@JdSkwDAbs;(Kg3?3OkV}4K*fo-U!|J_6GbA%z{ee~p2y#CGHVakNZh1j3=GSK z<2+)vK)f+X8rEhk4x@PnGi9;7G=3fdJhNWjCYb8Lq1i5@#Vo~BomLbki@Uhy4wwi6 z-E$)$zU@5nXB?7GC;g66x_=ZfCE`NR-<#laj)C-P2Z`Htw0teP*a(T>H8FojVCG*Y#-HZsv`O&Xssy5ci>G8^l%vt8M3Web7f`ekx4Hj8=hjqMzOu ze5FXtF0pA=XU+t;8l;`UzNog8%AzepzLe@`@&Rgz)#!_02b)B0%jx0p@f?bG+F!FF zs>5S>)3R%qpGZB?1n)1jL3!2st{?tL91&Kl&-=bmR&{+}*yX)TJki#^RY}$I0sH^e zn}Q%yQ%6H!i-8-=f!o0`#-{TMv5@_rMq`67ogkty*1eW}dsxSIAcDbQu+NG6a|Mnp z#)rm+S35bvMPd|IndO5V*?dz>t`a`sn1<*hy`I)f@PkSCI$_HDa$`!pzEzJw8DjYx zE-U@7zc{mg?dytMC^GJRUBE##bF8?&+Y9XA**{=o(C&QW%@P#J^a7W{*wjY8_I zS@?8`#pGCIC)#Cja$Eg@DLxh4rZE%+mklzizUlO4$T4~`A9xI_Ii z>P}%lM7cnX=JrvaF)_PD(A>=Fr#udhNZ$^OX1CG4P_JY;2KSGKL^nE(y538D;!Q{V z=JD6G>%!fVA-*wd@+3Hw{dF%Y-_=_HrCrj>wa5+b?(2|z&Jioe#reC@KLTD^7GAM6 z*Oq^4BOdDrQ#^h0+1|%eYgGCDBuNzLDkZNZx{Sk=7OQS9$~0Vu=%1cB+&6?fG_hQ^ zlFUv*{~QLS&34MJcd8?k-PdzdvtfVY7nX;gqAI?JNMSL^e&%>q6(sPyn|XbASkM2A z+^KJnu*LT|^Zjkvm}VvD8uyFHJm#FFJ!3&_Yjs?-1~@4MhkNks*3+xPj}`is{ek;0 zkN4L7?X5x7+ur}T3;%z7i~moD{x7l>+1ZQQn=^IoKh9P0Ke*+qc%wVK*N&&;G2ypH ztZ{2oZzmn!fK@O5;Px-MUX&a9RnMvP?bQs2v6>*qc5xSt3liF(*me%sj4m-~z6~{m z=V;-p=}1*;(39Rxld74k?;EzG^dB7h{oAsX;=NsT`U~4=Oj$ZQ~# z5PLtTqu^62D^xzVvem^RDwm|YV%G7|;ZQuuS>Mp&(HOvr;PwYFdbDtl4zDzO9)oHR zLbNL?dtU@b801Y0i|6-j;UU~kIG9o=TnK6)C}2o4_Q z1hS8G_81Mrxs%w8=6a6%A;${4R3rnw@n7{t3v4cS{FsUi>^hP)Ht1~W&pG-#?hHHs zg-NTjn%^ya9ATWy;=xeWC8D3s3E%eEs3^EgB+5OGJ%=EnM|V0DTJXj6ewTnG3jJXV zGU`+(_lW~485Q=;XYOxEhq>6HO_x+(@?zpn9;5`KCEXt&7fo)V5T2HVtzuaH(Buij zhiNWbg|^I0dl}4w4_;#gw9yYskv)2EnGT!8>z;ro)x~nN1d1jBs;y23#;c_JF!ItV zL+kSF4%%SW{sDEqD9=(nB2yO4XCV6``&KnYe9Ql0@2z6n3cEkuq-m&OW~MgSFjK=x z!^|9q$zf(@W@bCgoQ9bh8)j}8zT^4NjAk^WbAK+*mV9GbvbOi$TCd-=p5G%*8{z$P zH%hUM2r42~!Y|l#AUA$ykO6d5Hs@0lL#?ZF24Y|-D5a%MkZZy?(i!Eyt>sK{JFy4#cfK!33w6l+vzC zB7?pf;E2kWeX|aY@Gp29TkE2&>T(!J2itR29A93Su4~C|6%#Fyw!Jm0DUZDLsDMtl ztt^W1bB0aGT?{`+f$>IgTHJ(l%2`le3geW5Sijt)f$vPtnc3b-X4EN{nN&`0-n4FR ztc06IT*KrlA>n{n_gk@xh?e$DE%Gx>#Oi+1?`gnc9G|)No2+jL*DYU5QP~VoMJatY z66ywnuc19q%;;}moA3h(Y!glMT%S{DBtv-~Zp%-q9pHFcAGy(a+d%S{c(Dg8$v*nV z4GdHljPbR@fT&=YdXfR&?DJ`0qDOOI)dDmuTpT_}YzQzA1An zL})M3hyl>l98-Y{VTxeD(P)%9W1do-x;Z*2uR z`7iOR#jVA#ozPT%8;NF0SCnS+CgEg4;zK+vgK-awH&Z*5JAOqnawEHY$3Sf{8vuUG zzbs@|r`v7WYIJE^bM)3+=b_@3@0mx)g`0WN26E^%J_v7Ij%|5vuh{a`Rkczi?dJ&sdL&utLS%u-y)xtKHIaK9q zoF?Z84`U&8@n0S?3Og=CBuNu536`Tf^#cd|4On!nP0Tf9MVR?yKnwGAD%=aZG$Qr(2 zIIn+uYK7x$?+Awhj=B9oN|CW)ODi6^20?o)B7qc8@TB+8%g=7}F)gz&B zQbmz;^$_M-DEgtkb4r~c` z_*Z$xU*xX|&j|=7ztO$Y1(IV`y7Kv7zjxJ z|1bRi%1=5$S=8*CiU(N$DDHeB9h2qf{nurj*@61~lAZ)=^N)5HNVS;hKu+^GXAPEk z^v$jP17BJZzD9hQx4!KNDtxr*&XeU;UBesiGjb9P{;?TZTo{yp_d@umLy)}XDbh=0 zd^P*m@7XgpupHrDpx+cSVOtTj?YhRo%rCTG{Mr}ONU)DQ8iU7x>SBAYX);L zFQ_AUp>36a)Mfmfma;Zm0<7`_#)Z%f|0tr^*m_vU7aNaD5GNMU$ zr>cuA(tAophk{7EV_O!qXhqwLDo&jeEto+xg!5u)1wJr4I1z<9vU1u~!D-XoQ{Ong zk9|suJwHp%m=66*qp=zlWp91dc@sFsPq$^tO(%nvBh(QS!^v?_vpUi!Y>`};VJNIk zy@{c(kMUObp5Nu^7*A(JUEtN!B@f}!saSnEbmgr7B26`7zB)QqY^K0TWzrQv?)i;9 zE1xgtsMtZ-#`gxyaoWBmCDK_iJ^gK~l{6I0yMo zOhAPd2m5Pp(hFuBNW!U`9kyZteykzaTyadm0ju^R+vwK-x@RyV$XQ$L_rQpYD{dcn zAA?plH)Q0iB|~BW1RQsCbv&(DF$P)6cY@xyIBS}O2pG(x8i<@SJ`%2UiwW$$zr;;EN_4ay@gk=Q8~Kw^mkWb6n#aO%pR0y~|D~-goM0yGnt(yADdM_v zI$0o35Sghiql)B^h71GUVVvs3#?kfVzvWKdEtFD-;F_gHyHa9EQ*)Wre6!tXEuXyO zyJ;hJiU4JgEU;n%^-GE2u#Fm=%}hpN`H1FT9W@^IYxPe|0HIl?$9@V=P@yu^fW?Au%_E66)Y3 zOl6Dyq1atiX}kBZZ`6&G$n2sVc8Pd4t+P2d1?`Nj@~S>R#lpWDp1D5f

xJZoB7 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

  • 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 literal 0 HcmV?d00001 diff --git a/hooke.py b/hooke.py new file mode 100755 index 0000000..7e29328 --- /dev/null +++ b/hooke.py @@ -0,0 +1,752 @@ +#!/usr/bin/env python + +''' +HOOKE - A force spectroscopy review & analysis tool + +(C) 2008 Massimo Sandal + +Copyright (C) 2008 Massimo Sandal (University of Bologna, Italy). + +This program is released under the GNU General Public License version 2. +''' + +from libhooke import HOOKE_VERSION +from libhooke import WX_GOOD + +import os + +import wxversion +wxversion.select(WX_GOOD) +import wx +import wxmpl +from wx.lib.newevent import NewEvent + +import matplotlib.numerix as nx +import scipy as sp + +from threading import * +import Queue + +from hooke_cli import HookeCli +from libhooke import * +import libhookecurve as lhc + +#import file versions, just to know with what we're working... +from hooke_cli import __version__ as hookecli_version + +global __version__ +global events_from_gui +global config +global CLI_PLUGINS +global GUI_PLUGINS +global LOADED_PLUGINS +global PLOTMANIP_PLUGINS +global FILE_DRIVERS + +__version__=HOOKE_VERSION[0] +__release_name__=HOOKE_VERSION[1] + +events_from_gui=Queue.Queue() #GUI ---> CLI COMMUNICATION + +print 'Starting Hooke.' +#CONFIGURATION FILE PARSING +config_obj=HookeConfig() +config=config_obj.load_config('hooke.conf') + +#IMPORTING PLUGINS + +CLI_PLUGINS=[] +GUI_PLUGINS=[] +PLOTMANIP_PLUGINS=[] +LOADED_PLUGINS=[] + +plugin_commands_namespaces=[] +plugin_gui_namespaces=[] +for plugin_name in config['plugins']: + try: + plugin=__import__(plugin_name) + try: + eval('CLI_PLUGINS.append(plugin.'+plugin_name+'Commands)') #take Command plugin classes + plugin_commands_namespaces.append(dir(eval('plugin.'+plugin_name+'Commands'))) + except: + pass + try: + eval('GUI_PLUGINS.append(plugin.'+plugin_name+'Gui)') #take Gui plugin classes + plugin_gui_namespaces.append(dir(eval('plugin.'+plugin_name+'Gui'))) + except: + pass + except ImportError: + print 'Cannot find plugin ',plugin_name + else: + LOADED_PLUGINS.append(plugin_name) + print 'Imported plugin ',plugin_name + +#eliminate names common to all namespaces +for i in range(len(plugin_commands_namespaces)): + plugin_commands_namespaces[i]=[item for item in plugin_commands_namespaces[i] if (item != '__doc__' and item != '__module__' and item != '_plug_init')] +#check for conflicts in namespaces between plugins +#FIXME: only in commands now, because I don't have Gui plugins to check +#FIXME: how to check for plugin-defined variables (self.stuff) ?? +plugin_commands_names=[] +whatplugin_defines=[] +plugin_gui_names=[] +for namespace,plugin_name in zip(plugin_commands_namespaces, config['plugins']): + for item in namespace: + if item in plugin_commands_names: + i=plugin_commands_names.index(item) #we exploit the fact index gives the *first* occurrence of a name... + print 'Error. Plugin ',plugin_name,' defines a function already defined by ',whatplugin_defines[i],'!' + print 'This should not happen. Please disable one or both plugins and contact the plugin authors to solve the conflict.' + print 'Hooke cannot continue.' + exit() + else: + plugin_commands_names.append(item) + whatplugin_defines.append(plugin_name) + + +config['loaded_plugins']=LOADED_PLUGINS #FIXME: kludge -this should be global but not in config! +#IMPORTING DRIVERS +#FIXME: code duplication +FILE_DRIVERS=[] +LOADED_DRIVERS=[] +for driver_name in config['drivers']: + try: + driver=__import__(driver_name) + try: + eval('FILE_DRIVERS.append(driver.'+driver_name+'Driver)') + except: + pass + except ImportError: + print 'Cannot find driver ',driver_name + else: + LOADED_DRIVERS.append(driver_name) + print 'Imported driver ',driver_name +config['loaded_drivers']=LOADED_DRIVERS + +#LIST OF CUSTOM WX EVENTS FOR CLI ---> GUI COMMUNICATION +#FIXME: do they need to be here? +list_of_events={} + +plot_graph, EVT_PLOT = NewEvent() +list_of_events['plot_graph']=plot_graph + +plot_contact, EVT_PLOT_CONTACT = NewEvent() +list_of_events['plot_contact']=plot_contact + +measure_points, EVT_MEASURE_POINTS = NewEvent() +list_of_events['measure_points']=measure_points + +export_image, EVT_EXPORT_IMAGE = NewEvent() +list_of_events['export_image']=export_image + +close_plot, EVT_CLOSE_PLOT = NewEvent() +list_of_events['close_plot'] = close_plot + +show_plots, EVT_SHOW_PLOTS = NewEvent() +list_of_events['show_plots'] = show_plots + +get_displayed_plot, EVT_GET_DISPLAYED_PLOT = NewEvent() +list_of_events['get_displayed_plot'] = get_displayed_plot +#------------ + +class CliThread(Thread): + + def __init__(self,frame,list_of_events): + Thread.__init__(self) + + #here we have to put temporary references to pass to the cli object. + self.frame=frame + self.list_of_events=list_of_events + + self.debug=0 #to be used in the future + + def run(self): + print '\n\nThis is Hooke, version',__version__ , __release_name__ + print + print '(c) Massimo Sandal, 2006. Released under the GNU General Public License Version 2' + print 'Hooke is Free software.' + print '----' + print '' + + def make_command_class(*bases): + #FIXME: perhaps redundant + return type(HookeCli)("HookeCliPlugged", bases + (HookeCli,), {}) + cli = make_command_class(*CLI_PLUGINS)(self.frame,self.list_of_events,events_from_gui,config,FILE_DRIVERS) + cli.cmdloop() + +''' +GUI CODE + +FIXME: put it in a separate module in the future? +''' +class MainMenuBar(wx.MenuBar): + ''' + Creates the menu bar + ''' + def __init__(self): + wx.MenuBar.__init__(self) + '''the menu description. the key of the menu is XX&Menu, where XX is a number telling + the order of the menus on the menubar. + &Menu is the Menu text + the corresponding argument is ('&Item', 'itemname'), where &Item is the item text and itemname + the inner reference to use in the self.menu_items dictionary. + + See create_menus() to see how it works + + Note: the mechanism on page 124 of "wxPython in Action" is less awkward, maybe, but I want + binding to be performed later. Perhaps I'm wrong :) + ''' + + self.menu_desc={'00&File':[('&Open playlist','openplaymenu'),('&Exit','exitmenu')], + '01&Edit':[('&Export text...','exporttextmenu'),('&Export image...','exportimagemenu')], + '02&Help':[('&About Hooke','aboutmenu')]} + self.create_menus() + + def create_menus(self): + ''' + Smartish routine to create the menu from the self.menu_desc dictionary + Hope it's a workable solution for the future. + ''' + self.menus=[] #the menu objects to append to the menubar + self.menu_items={} #the single menu items dictionary, to bind to events + + names=self.menu_desc.keys() #we gotta sort, because iterating keys goes in odd order + names.sort() + + for name in names: + self.menus.append(wx.Menu()) + for menu_item in self.menu_desc[name]: + self.menu_items[menu_item[1]]=self.menus[-1].Append(-1, menu_item[0]) + + for menu,name in zip(self.menus,names): + self.Append(menu,name[2:]) + +class MainPanel(wx.Panel): + def __init__(self,parent,id): + + wx.Panel.__init__(self,parent,id) + self.splitter = wx.SplitterWindow(self) + +ID_FRAME=100 +class MainWindow(wx.Frame): + '''we make a frame inheriting wx.Frame and setting up things on the init''' + def __init__(self,parent,id,title): + + #----------------------------- + #WX WIDGETS INITIALIZATION + + wx.Frame.__init__(self,parent,ID_FRAME,title,size=(800,600),style=wx.DEFAULT_FRAME_STYLE|wx.NO_FULL_REPAINT_ON_RESIZE) + + self.mainpanel=MainPanel(self,-1) + self.cpanels=[] + + self.cpanels.append(wx.Panel(self.mainpanel.splitter,-1)) + self.cpanels.append(wx.Panel(self.mainpanel.splitter,-1)) + + self.statusbar=wx.StatusBar(self,-1) + self.SetStatusBar(self.statusbar) + + self.mainmenubar=MainMenuBar() + self.SetMenuBar(self.mainmenubar) + + self.controls=[] + self.figures=[] + self.axes=[] + + #This is our matplotlib plot + self.controls.append(wxmpl.PlotPanel(self.cpanels[0],-1)) + self.controls.append(wxmpl.PlotPanel(self.cpanels[1],-1)) + #These are our figure and axes, so to have easy references + #Also, we initialize + self.figures=[control.get_figure() for control in self.controls] + self.axes=[figure.gca() for figure in self.figures] + + self.cpanels[1].Hide() + self.mainpanel.splitter.Initialize(self.cpanels[0]) + + self.sizer_dance() #place/size the widgets + + self.controls[0].SetSize(self.cpanels[0].GetSize()) + self.controls[1].SetSize(self.cpanels[1].GetSize()) + + #------------------------------------------- + #NON-WX WIDGETS INITIALIZATION + + #Flags. + self.click_plot=0 + + #FIXME: These could become a single flag with different (string?) values + self.on_measure_distance=False + self.on_measure_force=False + + self.plot_fit=False + + #Number of points to be clicked + self.num_of_points = 2 + + #Data. + self.current_x_ext=[[],[]] + self.current_y_ext=[[],[]] + self.current_x_ret=[[],[]] + self.current_y_ret=[[],[]] + + self.current_x_unit=[None,None] + self.current_y_unit=[None,None] + + #Initialize xaxes, yaxes + #FIXME: should come from config + self.current_xaxes=0 + self.current_yaxes=0 + + #Other + + + self.index_buffer=[] + + self.clicked_points=[] + + self.events_from_gui = events_from_gui + + ''' + This dictionary keeps all the flags and the relative functon names that + have to be called when a point is clicked. + That is: + - if point is clicked AND foo_flag=True + - foo() + + Conversely, foo_flag is True if a corresponding event is launched by the CLI. + + self.ClickedPoints() takes care of handling this + ''' + + self.click_flags_functions={'measure_points':[False, 'MeasurePoints']} + + #Custom events from CLI --> GUI functions! + #FIXME: Should use the self.Bind() syntax + EVT_PLOT(self, self.PlotCurve) + EVT_PLOT_CONTACT(self, self.PlotContact) + EVT_GET_DISPLAYED_PLOT(self, self.OnGetDisplayedPlot) + EVT_MEASURE_POINTS(self, self.OnMeasurePoints) + EVT_EXPORT_IMAGE(self,self.ExportImage) + EVT_CLOSE_PLOT(self, self.OnClosePlot) + EVT_SHOW_PLOTS(self, self.OnShowPlots) + + #This event and control decide what happens when I click on the plot 0. + wxmpl.EVT_POINT(self, self.controls[0].GetId(), self.ClickPoint0) + wxmpl.EVT_POINT(self, self.controls[1].GetId(), self.ClickPoint1) + + #RUN PLUGIN-SPECIFIC INITIALIZATION + #make sure we execute _plug_init() for every command line plugin we import + for plugin_name in config['plugins']: + try: + plugin=__import__(plugin_name) + try: + eval('plugin.'+plugin_name+'Gui._plug_init(self)') + pass + except AttributeError: + pass + except ImportError: + pass + + + + #WX-SPECIFIC FUNCTIONS + def sizer_dance(self): + ''' + adjust size and placement of wxpython widgets. + ''' + self.splittersizer = wx.BoxSizer(wx.VERTICAL) + self.splittersizer.Add(self.mainpanel.splitter, 1, wx.EXPAND) + + self.plot1sizer = wx.BoxSizer() + self.plot1sizer.Add(self.controls[0], 1, wx.EXPAND) + + self.plot2sizer = wx.BoxSizer() + self.plot2sizer.Add(self.controls[1], 1, wx.EXPAND) + + self.panelsizer=wx.BoxSizer() + self.panelsizer.Add(self.mainpanel, -1, wx.EXPAND) + + self.cpanels[0].SetSizer(self.plot1sizer) + self.cpanels[1].SetSizer(self.plot2sizer) + + self.mainpanel.SetSizer(self.splittersizer) + self.SetSizer(self.panelsizer) + + def binding_dance(self): + self.Bind(wx.EVT_MENU, self.OnOpenPlayMenu, self.menubar.menu_items['openplaymenu']) + self.Bind(wx.EVT_MENU, self.OnExitMenu, self.menubar.menu_items['exitmenu']) + self.Bind(wx.EVT_MENU, self.OnExportText, self.menubar.menu_items['exporttextmenu']) + self.Bind(wx.EVT_MENU, self.OnExportImage, self.menubar.menu_items['exportimagemenu']) + self.Bind(wx.EVT_MENU, self.OnAboutMenu, self.menubar.menu_items['aboutmenu']) + + # DOUBLE PLOT MANAGEMENT + #---------------------- + def show_both(self): + ''' + Shows both plots. + ''' + self.mainpanel.splitter.SplitHorizontally(self.cpanels[0],self.cpanels[1]) + self.mainpanel.splitter.SetSashGravity(0.5) + self.mainpanel.splitter.SetSashPosition(300) #FIXME: we should get it and restore it + self.mainpanel.splitter.UpdateSize() + + def close_plot(self,plot): + ''' + Closes one plot - only if it's open + ''' + if not self.cpanels[plot].IsShown(): + return + if plot != 0: + self.current_plot_dest = 0 + else: + self.current_plot_dest = 1 + self.cpanels[plot].Hide() + self.mainpanel.splitter.Unsplit(self.cpanels[plot]) + self.mainpanel.splitter.UpdateSize() + + + def OnClosePlot(self,event): + self.close_plot(event.to_close) + + def OnShowPlots(self,event): + self.show_both() + + + #FILE MENU FUNCTIONS + #-------------------- + def OnOpenPlayMenu(self, event): + pass + + def OnExitMenu(self,event): + pass + + def OnExportText(self,event): + pass + + def OnExportImage(self,event): + pass + + def OnAboutMenu(self,event): + pass + + #PLOT INTERACTION + #---------------- + def PlotCurve(self,event): + ''' + plots the current ext,ret curve. + ''' + dest=0 + + #FIXME: BAD kludge following. There should be a well made plot queue mechanism, with replacements etc. + #--- + #If we have only one plot in the event, we already have one in self.plots and this is a secondary plot, + #do not erase self.plots but append the new plot to it. + if len(event.plots) == 1 and event.plots[0].destination != 0 and len(self.plots) == 1: + self.plots.append(event.plots[0]) + #if we already have two plots and a new secondary plot comes, we substitute the previous + if len(event.plots) == 1 and event.plots[0].destination != 0 and len(self.plots) > 1: + self.plots[1] = event.plots[0] + else: + self.plots = event.plots + + #FIXME. Should be in PlotObject, somehow + c=0 + for plot in self.plots: + if self.plots[c].styles==[]: + self.plots[c].styles=[None for item in plot.vectors] + + for plot in self.plots: + ''' + MAIN LOOP FOR ALL PLOTS (now only 2 are allowed but...) + ''' + if 'destination' in dir(plot): + dest=plot.destination + + #if the requested panel is not shown, show it + if not ( self.cpanels[dest].IsShown() ): + self.show_both() + + self.axes[dest].hold(False) + self.current_vectors=plot.vectors + self.current_title=plot.title + self.current_plot_dest=dest #let's try this way to take into account the destination plot... + + c=0 + for vectors_to_plot in self.current_vectors: + if len(vectors_to_plot)==2: #3d plots are to come... + if len(plot.styles) > 0 and plot.styles[c] == 'scatter': + self.axes[dest].scatter(vectors_to_plot[0],vectors_to_plot[1]) + else: + self.axes[dest].plot(vectors_to_plot[0],vectors_to_plot[1]) + + self.axes[dest].hold(True) + c+=1 + else: + pass + + #FIXME: tackles only 2d plots + self.axes[dest].set_xlabel(plot.units[0]) + self.axes[dest].set_ylabel(plot.units[1]) + + #FIXME: set smaller fonts + self.axes[dest].set_title(plot.title) + + if plot.xaxes: + #swap X axis + xlim=self.axes[dest].get_xlim() + self.axes[dest].set_xlim((xlim[1],xlim[0])) + if plot.yaxes: + #swap Y axis + ylim=self.axes[dest].get_ylim() + self.axes[dest].set_ylim((ylim[1],ylim[0])) + + self.controls[dest].draw() + + + def PlotContact(self,event): + ''' + plots the contact point + ''' + self.axes[0].hold(True) + self.current_contact_index=event.contact_index + + #now we fake a clicked point + self.clicked_points.append(ClickedPoint()) + self.clicked_points[-1].absolute_coords=self.current_x_ret[dest][self.current_contact_index], self.current_y_ret[dest][self.current_contact_index] + self.clicked_points[-1].is_marker=True + + self._replot() + self.clicked_points=[] + + + def ClickPoint0(self,event): + self.current_plot_dest=0 + self.ClickPoint(event) + def ClickPoint1(self,event): + self.current_plot_dest=1 + self.ClickPoint(event) + + def ClickPoint(self,event): + ''' + this function decides what to do when we receive a left click on the axes. + We trigger other functions: + - the action chosen by the CLI sends an event + - the event raises a flag : self.click_flags_functions['foo'][0] + - the raised flag wants the function in self.click_flags_functions[1] to be called after a click + ''' + for key, value in self.click_flags_functions.items(): + if value[0]: + eval('self.'+value[1]+'(event)') + + def OnMeasurePoints(self,event): + ''' + trigger flags to measure N points + ''' + self.click_flags_functions['measure_points'][0]=True + if 'num_of_points' in dir(event): + self.num_of_points=event.num_of_points + + + def MeasurePoints(self,event,current_set=1): + dest=self.current_plot_dest + try: + current_set=event.set + except AttributeError: + pass + + #find the current plot matching the clicked destination + plot=self._plot_of_dest() + if len(plot.vectors)-1 < current_set: #what happens if current_set is 1 and we have only 1 vector? + current_set=current_set-len(plot.vectors) + + xvector=plot.vectors[current_set][0] + yvector=plot.vectors[current_set][1] + + self.clicked_points.append(ClickedPoint()) + self.clicked_points[-1].absolute_coords=event.xdata, event.ydata + self.clicked_points[-1].find_graph_coords(xvector,yvector) + self.clicked_points[-1].is_marker=True + self.clicked_points[-1].is_line_edge=True + self.clicked_points[-1].dest=dest + + self._replot() + + if len(self.clicked_points)==self.num_of_points: + self.events_from_gui.put(self.clicked_points) + #restore to default state: + self.clicked_points=[] + self.click_flags_functions['measure_points'][0]=False + + + def OnGetDisplayedPlot(self,event): + if 'dest' in dir(event): + self.GetDisplayedPlot(event.dest) + else: + self.GetDisplayedPlot(self.current_plot_dest) + + def GetDisplayedPlot(self,dest): + ''' + returns to the CLI the currently displayed plot for the given destination + ''' + displayed_plot=self._plot_of_dest(dest) + events_from_gui.put(displayed_plot) + + def ExportImage(self,event): + ''' + exports an image as a file. + Current supported file formats: png, eps + (matplotlib docs say that jpeg should be supported too, but with .jpg it doesn't work for me!) + ''' + #dest=self.current_plot_dest + dest=event.dest + filename=event.name + self.figures[dest].savefig(filename) + + def _find_nearest_point(self, mypoint, dataset=1): + ''' + Given a clicked point on the plot, finds the nearest point in the dataset (in X) that + corresponds to the clicked point. + ''' + dest=self.current_plot_dest + + xvector=plot.vectors[dataset][0] + yvector=plot.vectors[dataset][1] + + #Ye Olde sorting algorithm... + #FIXME: is there a better solution? + index=0 + best_index=0 + best_diff=10^9 #hope we never go over this magic number :( + for point in xvector: + diff=abs(point-mypoint) + if diff 0 and plot.styles[c]=='scatter': + self.axes[dest].scatter(plotset[0], plotset[1]) + else: + self.axes[dest].plot(plotset[0], plotset[1]) + c+=1 + #plot points we have clicked + for item in self.clicked_points: + if item.is_marker: + if item.graph_coords==(None,None): #if we have no graph coords, we display absolute coords + self.axes[dest].scatter([item.absolute_coords[0]],[item.absolute_coords[1]]) + else: + self.axes[dest].scatter([item.graph_coords[0]],[item.graph_coords[1]]) + + if self.plot_fit: + print 'DEBUGGING WARNING: use of self.plot_fit is deprecated!' + self.axes[dest].plot(self.plot_fit[0],self.plot_fit[1]) + + self.axes[dest].hold(True) + #set old axes again + self.axes[dest].set_xlim(xlim) + self.axes[dest].set_ylim(ylim) + #set title and names again... + self.axes[dest].set_title(self.current_title) + self.axes[dest].set_xlabel(plot.units[0]) + self.axes[dest].set_ylabel(plot.units[1]) + #and redraw! + self.controls[dest].draw() + + +class MySplashScreen(wx.SplashScreen): + """ + Create a splash screen widget. + That's just a fancy addition... every serious application has a splash screen! + """ + def __init__(self, frame): + # This is a recipe to a the screen. + # Modify the following variables as necessary. + #aBitmap = wx.Image(name = "wxPyWiki.jpg").ConvertToBitmap() + aBitmap=wx.Image(name='hooke.jpg').ConvertToBitmap() + splashStyle = wx.SPLASH_CENTRE_ON_SCREEN | wx.SPLASH_TIMEOUT + splashDuration = 2000 # milliseconds + splashCallback = None + # Call the constructor with the above arguments in exactly the + # following order. + wx.SplashScreen.__init__(self, aBitmap, splashStyle, + splashDuration, None, -1) + wx.EVT_CLOSE(self, self.OnExit) + self.frame=frame + wx.Yield() + + def OnExit(self, evt): + self.Hide() + + self.frame.Show() + # The program will freeze without this line. + evt.Skip() # Make sure the default handler runs too... + + +#------------------------------------------------------------------------------ + +def main(): + + #save the directory where Hooke is located + config['hookedir']=os.getcwd() + + #now change to the working directory. + try: + os.chdir(config['workdir']) + except OSError: + print "Warning: Invalid work directory." + + app=wx.PySimpleApp() + + def make_gui_class(*bases): + return type(MainWindow)("MainWindowPlugged", bases + (MainWindow,), {}) + + main_frame = make_gui_class(*GUI_PLUGINS)(None, -1, ('Hooke '+__version__)) + + #FIXME. The frame.Show() is called by the splashscreen here! Ugly as hell. + + mysplash=MySplashScreen(main_frame) + mysplash.Show() + + my_cmdline=CliThread(main_frame, list_of_events) + my_cmdline.start() + + + app.MainLoop() + +main() diff --git a/hooke_cli.py b/hooke_cli.py new file mode 100755 index 0000000..8ed2ca0 --- /dev/null +++ b/hooke_cli.py @@ -0,0 +1,871 @@ +#!/usr/bin/env python + +''' +hooke_cli.py + +Command line module of Hooke. + +Copyright (C) 2006 Massimo Sandal (University of Bologna, Italy). + +This program is released under the GNU General Public License version 2. +''' + + +from libhooke import * #FIXME +import libhookecurve as lhc + +from libhooke import WX_GOOD +from libhooke import HOOKE_VERSION + +import wxversion +wxversion.select(WX_GOOD) +import wx + +from wx.lib.newevent import NewEvent +from matplotlib.numerix import * #FIXME + +import xml.dom.minidom +import sys, os, os.path, glob, shutil +import Queue +import cmd +import time + +global __version__ +global __codename__ +global __releasedate__ +__version__ = HOOKE_VERSION[0] +__codename__ = HOOKE_VERSION[1] +__releasedate__ = HOOKE_VERSION[2] + +from matplotlib import __version__ as mpl_version +from wx import __version__ as wx_version +from wxmpl import __version__ as wxmpl_version +from scipy import __version__ as scipy_version +from numpy import __version__ as numpy_version +from sys import version as python_version +import platform + + +class HookeCli(cmd.Cmd): + + def __init__(self,frame,list_of_events,events_from_gui,config,drivers): + cmd.Cmd.__init__(self) + + self.prompt = 'hooke: ' + + + self.current_list=[] #the playlist we're using + + self.current=None #the current curve under analysis. + self.plots=None + ''' + The actual hierarchy of the "current curve" is a bit complex: + + self.current = the lhc.HookeCurve container object of the current curve + self.current.curve = the current "real" curve object as defined in the filetype driver class + self.current.curve.default_plots() = the default plots of the filetype driver. + + The plot objects obtained by mean of self.current.curve.default_plots() + then undergoes modifications by the plotmanip + modifier functions. The modified plot is saved in self.plots and used if needed by other functions. + ''' + + + self.pointer=0 #a pointer to navigate the current list + + #Things that come from outside + self.frame=frame #the wx frame we refer to + self.list_of_events=list_of_events #a list of wx events we use to interact with the GUI + self.events_from_gui=events_from_gui #the Queue object we use to have messages from the GUI + self.config=config #the configuration dictionary + self.drivers=drivers #the file format drivers + + #get plot manipulation functions + plotmanip_functions=[] + for object_name in dir(self): + if object_name[0:9]=='plotmanip': + plotmanip_functions.append(getattr(self,object_name)) + #put plotmanips in order + self.plotmanip=[None for item in self.config['plotmanips']] + for item in plotmanip_functions: + namefunction=item.__name__[10:] + if namefunction in self.config['plotmanips']: + nameindex=self.config['plotmanips'].index(namefunction) #index of function in plotmanips config + self.plotmanip[nameindex] = item + else: + pass + + + self.playlist_saved=0 + self.playlist_name='' + self.notes_saved=1 + + #Data that must be saved in the playlist, related to the whole playlist (not individual curves) + self.playlist_generics={} + + #make sure we execute _plug_init() for every command line plugin we import + for plugin_name in self.config['plugins']: + try: + plugin=__import__(plugin_name) + try: + eval('plugin.'+plugin_name+'Commands._plug_init(self)') + except AttributeError: + pass + except ImportError: + pass + + +#HELPER FUNCTIONS +#Everything sending an event should be here + def _measure_N_points(self, N, whatset=1): + ''' + general helper function for N-points measures + ''' + measure_points=self.list_of_events['measure_points'] + wx.PostEvent(self.frame, measure_points(num_of_points=N, set=whatset)) + while 1: + try: + points=self.frame.events_from_gui.get() + break + except Empty: + pass + return points + + def _get_displayed_plot(self,dest=0): + ''' + returns the currently displayed plot. + ''' + wx.PostEvent(self.frame, self.list_of_events['get_displayed_plot'](dest=dest)) + while 1: + try: + displayed_plot=self.events_from_gui.get() + except Empty: + pass + if displayed_plot: + break + return displayed_plot + + def _send_plot(self,plots): + ''' + sends a plot to the GUI + ''' + wx.PostEvent(self.frame, self.list_of_events['plot_graph'](plots=plots)) + return + + def _find_plotmanip(self, name): + ''' + returns a plot manipulator function from its name + ''' + return self.plotmanip[self.config['plotmanips'].index(name)] + +#HERE COMMANDS BEGIN + + def help_set(self): + print ''' +SET +Sets a local configuration variable +------------- +Syntax: set [variable] [value] + ''' + def do_set(self,args): + #FIXME: some variables in self.config should be hidden or intelligently configurated... + args=args.split() + if len(args)==0: + print 'You must specify a variable and a value' + print 'Available variables:' + print self.config.keys() + return + if args[0] not in self.config.keys(): + print 'This is not an internal Hooke variable!' + return + if len(args)==1: + #FIXME:we should reload the config file and reset the config value + print self.config[args[0]] + return + key=args[0] + try: #try to have a numeric value + value=float(args[1]) + except ValueError: #if it cannot be converted to float, it's None, or a string... + if value.lower()=='none': + value=None + else: + value=args[1] + + self.config[key]=value + self.do_plot(0) + +#PLAYLIST MANAGEMENT AND NAVIGATION +#------------------------------------ + + def help_loadlist(self): + print ''' +LOADLIST +Loads a file playlist +----------- +Syntax: loadlist [playlist file] + ''' + def do_loadlist(self, args): + #checking for args: if nothing is given as input, we warn and exit. + while len(args)==0: + args=raw_input('File to load?') + + arglist=args.split() + play_to_load=arglist[0] + + #We assume a Hooke playlist has the extension .hkp + if play_to_load[-4:] != '.hkp': + play_to_load+='.hkp' + + try: + playxml=PlaylistXML() + self.current_list, self.playlist_generics=playxml.load(play_to_load) + self.current_playxml=playxml + except IOError: + print 'File not found.' + return + + print 'Loaded %s curves' %len(self.current_list) + + if 'pointer' in self.playlist_generics.keys(): + self.pointer=int(self.playlist_generics['pointer']) + else: + #if no pointer is found, set the current curve as the first curve of the loaded playlist + self.pointer=0 + print 'Starting at curve ',self.pointer + + self.current=self.current_list[self.pointer] + + #resets saved/notes saved state + self.playlist_saved=0 + self.playlist_name='' + self.notes_saved=0 + + self.do_plot(0) + + + def help_genlist(self): + print ''' +GENLIST +Generates a file playlist. +Note it doesn't *save* it: see savelist for this. + +If [input files] is a directory, it will use all files in the directory for playlist. +So: +genlist dir +genlist dir/ +genlist dir/*.* + +are all equivalent syntax. +------------ +Syntax: genlist [input files] + +''' + def do_genlist(self,args): + #args list is: input path, output name + if len(args)==0: + args=raw_input('Input files?') + + arglist=args.split() + list_path=arglist[0] + + #if it's a directory, is like /directory/*.* + #FIXME: probably a bit kludgy. + if os.path.isdir(list_path): + if platform.system == 'Windows': + SLASH="\\" + else: + SLASH="/" + if list_path[-1] == SLASH: + list_path=list_path+'*.*' + else: + list_path=list_path+SLASH+'*.*' + + #expanding correctly the input list with the glob module :) + list_files=glob.glob(list_path) + list_files.sort() + + self.current_list=[] + for item in list_files: + try: + self.current_list.append(lhc.HookeCurve(os.path.abspath(item))) + except: + pass + + self.pointer=0 + if len(self.current_list)>0: + self.current=self.current_list[self.pointer] + else: + print 'Empty list!' + return + + #resets saved/notes saved state + self.playlist_saved=0 + self.playlist_name='' + self.notes_saved=0 + + self.do_plot(0) + + + def do_savelist(self,args): + ''' + SAVELIST + Saves the current file playlist on disk. + ------------ + Syntax: savelist [filename] + ''' + while len(args)==0: + args=raw_input('Input files?') + + output_filename=args + + self.playlist_generics['pointer']=self.pointer + + #autocomplete filename if not specified + if output_filename[-4:] != '.hkp': + output_filename+='.hkp' + + playxml=PlaylistXML() + playxml.export(self.current_list, self.playlist_generics) + playxml.save(output_filename) + + #remembers we have saved playlist + self.playlist_saved=1 + + def help_addtolist(self): + print ''' +ADDTOLIST +Adds a file to the current playlist +-------------- +Syntax: addtolist [filename] +''' + def do_addtolist(self,args): + #args list is: input path + if len(args)==0: + print 'You must give the input filename you want to add' + self.help_addtolist() + return + + filenames=glob.glob(args) + + for filename in filenames: + self.current_list.append(lhc.HookeCurve(os.path.abspath(filename))) + #we need to save playlist + self.playlist_saved=0 + + def help_printlist(self): + print ''' +PRINTLIST +Prints the list of curves in the current playlist +------------- +Syntax: printlist +''' + def do_printlist(self,args): + for item in self.current_list: + print item.path + + + def help_jump(self): + print ''' +JUMP +Jumps to a given curve. +------ +Syntax: jump {$curve} + +If the curve is not in the current playlist, it politely asks if we want to add it. + ''' + def do_jump(self,filename): + ''' + jumps to the curve with the given filename. + if the filename is not in the playlist, it asks if we must add it or not. + ''' + + if filename=='': + filename=raw_input('Jump to?') + + filepath=os.path.abspath(filename) + print filepath + + c=0 + item_not_found=1 + while item_not_found: + try: + + if self.current_list[c].path == filepath: + self.pointer=c + self.current=self.current_list[self.pointer] + item_not_found=0 + self.do_plot(0) + else: + c+=1 + except IndexError: + #We've found the end of the list. + answer=raw_input('Curve not found in playlist. Add it to list?') + if answer.lower()[0]=='y': + try: + self.do_addtolist(filepath) + except: + print 'Curve file not found.' + return + self.current=self.current_list[-1] + self.pointer=(len(current_list)-1) + self.do_plot(0) + + item_not_found=0 + + + def do_index(self,args): + ''' + INDEX + Prints the index of the current curve in the list + ----- + Syntax: index + ''' + print self.pointer+1, 'of', len(self.current_list) + + + def help_next(self): + print ''' +NEXT +Go the next curve in the playlist. +If we are at the last curve, we come back to the first. +----- +Syntax: next, n + ''' + def do_next(self,args): + self.current.curve.close_all() + if self.pointer == (len(self.current_list)-1): + self.pointer=0 + print 'Playlist finished; back to first curve.' + else: + self.pointer+=1 + + self.current=self.current_list[self.pointer] + self.do_plot(0) + + + def help_n(self): + self.help_next() + def do_n(self,args): + self.do_next(args) + + def help_previous(self,args): + print ''' +PREVIOUS +Go to the previous curve in the playlist. +If we are at the first curve, we jump to the last. +------- +Syntax: previous, p + ''' + def do_previous(self,args): + self.current.curve.close_all() + if self.pointer == 0: + self.pointer=(len(self.current_list)-1) + print 'Start of playlist; jump to last curve.' + else: + self.pointer-=1 + + self.current=self.current_list[self.pointer] + self.do_plot(args) + + + def help_p(self): + self.help_previous() + def do_p(self,args): + self.do_previous(args) + + +#PLOT INTERACTION COMMANDS +#------------------------------- + def help_plot(self): + print ''' +PLOT +Plots the current force curve +------- +Syntax: plot + ''' + def do_plot(self,args): + + self.current.identify(self.drivers) + self.plots=self.current.curve.default_plots() + try: + self.plots=self.current.curve.default_plots() + except Exception, e: + print 'Unexpected error occurred in do_plot().' + print e + return + + #apply the plotmanip functions eventually present + nplots=len(self.plots) + c=0 + while c 1: + dest=int(args[1]) + + export_image=self.list_of_events['export_image'] + wx.PostEvent(self.frame, export_image(name=name, dest=dest)) + + + def help_txt(self): + print ''' +TXT +Saves the current curve as a text file +Columns are, in order: +X1 , Y1 , X2 , Y2 , X3 , Y3 ... + +------------- +Syntax: txt [filename] {plot to export} + ''' + def do_txt(self,args): + + def transposed2(lists, defval=0): + ''' + transposes a list of lists, i.e. from [[a,b,c],[x,y,z]] to [[a,x],[b,y],[c,z]] without losing + elements + (by Zoran Isailovski on the Python Cookbook online) + ''' + if not lists: return [] + return map(lambda *row: [elem or defval for elem in row], *lists) + + whichplot=0 + args=args.split() + if len(args)==0: + filename=raw_input('Filename?') + else: + filename=args[0] + try: + whichplot=int(args[1]) + except: + pass + + columns=[] + for dataset in self.plots[whichplot].vectors: + for i in range(0,len(dataset)): + columns.append([]) + for value in dataset[i]: + columns[-1].append(str(value)) + + rows=transposed2(columns, 'nan') + rows=[' , '.join(item) for item in rows] + text='\n'.join(rows) + + txtfile=open(filename,'w+') + txtfile.write(text) + txtfile.close() + + + #LOGGING, REPORTING, NOTETAKING + def help_note(self): + print ''' +NOTE +Writes or displays a note about the current curve. +If [anything] is empty, it displays the note, otherwise it adds a note. +The note is then saved in the playlist if you issue a savelist command +--------------- +Syntax: note [anything] +''' + def do_note(self,args): + if args=='': + print self.current_list[self.pointer].notes + else: + #bypass UnicodeDecodeError troubles + try: + args=args.decode('ascii') + except: + args=args.decode('ascii','ignore') + if len(args)==0: + args='?' + + self.current_list[self.pointer].notes=args + self.notes_saved=0 + + def help_notelog(self): + print ''' +NOTELOG +Writes a log of the notes taken during the session for the current +playlist +-------------- +Syntax notelog [filename] +''' + def do_notelog(self,args): + + if len(args)==0: + args=raw_input('Notelog filename?') + + note_lines='Notes taken at '+time.asctime()+'\n' + for item in self.current_list: + if len(item.notes)>0: + #FIXME: log should be justified + #FIXME: file path should be truncated... + note_string=(item.path+' | '+item.notes+'\n') + note_lines+=note_string + + try: + f=open(args,'a+') + f.write(note_lines) + f.close() + except IOError, (ErrorNumber, ErrorMessage): + print 'Error: notes cannot be saved. Catched exception:' + print ErrorMessage + + self.notes_saved=1 + + def help_copylog(self): + print ''' +COPYLOG +Moves the annotated curves to another directory +----------- +Syntax copylog [directory] + ''' + def do_copylog(self,args): + + if len(args)==0: + args=raw_input('Destination directory?') + + mydir=os.path.abspath(args) + if not os.path.isdir(mydir): + print 'Destination is not a directory.' + return + + for item in self.current_list: + if len(item.notes)>0: + try: + shutil.copy(item.path, mydir) + except OSError: + print 'OSError. Cannot copy file. Perhaps you gave me a wrong directory?' + + +#OS INTERACTION COMMANDS +#----------------- + def help_dir(self): + print ''' +DIR, LS +Lists the files in the directory +--------- +Syntax: dir [path] + ls [path] + ''' + def do_dir(self,args): + + if len(args)==0: + args='*' + print glob.glob(args) + + def help_ls(self): + self.help_dir(self) + def do_ls(self,args): + self.do_dir(args) + + def help_pwd(self): + print ''' +PWD +Gives the current working directory. +------------ +Syntax: pwd + ''' + def do_pwd(self,args): + print os.getcwd() + + def help_cd(self): + print ''' +CD +Changes the current working directory +----- +Syntax: cd + ''' + def do_cd(self,args): + mypath=os.path.abspath(args) + try: + os.chdir(mypath) + except OSError: + print 'I cannot access that directory.' + + + def help_system(self): + print ''' +SYSTEM +Executes a system command line and reports the output +----- +Syntax system [command line] + ''' + pass + def do_system(self,args): + waste=os.system(args) + + def do_debug(self,args): + ''' + this is a dummy command where I put debugging things + ''' + print self.config['plotmanips'] + pass + + def help_current(self): + print ''' +CURRENT +Prints the current curve path. +------ +Syntax: current + ''' + def do_current(self,args): + print self.current.path + + def do_version(self,args): + ''' + VERSION + ------ + Prints the current version and codename, plus library version. Useful for debugging. + ''' + print 'Hooke '+__version__+' ('+__codename__+')' + print 'Released on: '+__releasedate__ + print '---' + print 'Python version: '+python_version + print 'WxPython version: '+wx_version + print 'wxMPL version: '+wxmpl_version + print 'Matplotlib version: '+mpl_version + print 'SciPy version: '+scipy_version + print 'NumPy version: '+numpy_version + print '---' + print 'Platform: '+str(platform.uname()) + print '---' + print 'Loaded plugins:',self.config['loaded_plugins'] + + def help_exit(self): + print ''' +EXIT, QUIT +Exits the program cleanly. +------ +Syntax: exit +Syntax: quit +''' + def do_exit(self,args): + we_exit='N' + + if (not self.playlist_saved) or (not self.notes_saved): + we_exit=raw_input('You did not save your playlist and/or notes. Exit?') + else: + we_exit=raw_input('Exit?') + + if we_exit[0].upper()=='Y': + wx.CallAfter(self.frame.Close) + sys.exit(0) + else: + return + + def help_quit(self): + self.help_exit() + def do_quit(self,args): + self.do_exit(args) + + + +if __name__ == '__main__': + mycli=HookeCli(0) + mycli.cmdloop() \ No newline at end of file diff --git a/libhooke.py b/libhooke.py new file mode 100755 index 0000000..65b9636 --- /dev/null +++ b/libhooke.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python + +''' +libhooke.py + +General library of internal objects and utilities for Hooke. + +Copyright (C) 2006 Massimo Sandal (University of Bologna, Italy). +With algorithms contributed by Francesco Musiani (University of Bologna, Italy) + +This program is released under the GNU General Public License version 2. +''' + + + +import libhookecurve as lhc + +import scipy +import scipy.signal +import scipy.optimize +import scipy.stats +import numpy +import xml.dom.minidom +import os +import string +import csv + +HOOKE_VERSION=['0.8.3', 'Seinei', '2008-04-16'] +WX_GOOD=['2.6','2.8'] + +class PlaylistXML: + ''' + This module allows for import/export of an XML playlist into/out of a list of HookeCurve objects + ''' + + def __init__(self): + + self.playlist=None #the DOM object representing the playlist data structure + self.playpath=None #the path of the playlist XML file + self.plaything=None + self.hidden_attributes=['curve'] #This list contains hidden attributes that we don't want to go into the playlist. + + def export(self, list_of_hooke_curves, generics): + ''' + Creates an initial playlist from a list of files. + A playlist is an XML document with the following syntaxis: + + + + + ''' + + #create the output playlist, a simple XML document + impl=xml.dom.minidom.getDOMImplementation() + #create the document DOM object and the root element + newdoc=impl.createDocument(None, "playlist",None) + top_element=newdoc.documentElement + + #save generics variables + playlist_generics=newdoc.createElement("generics") + top_element.appendChild(playlist_generics) + for key in generics.keys(): + newdoc.createAttribute(key) + playlist_generics.setAttribute(key,str(generics[key])) + + #save curves and their attributes + for item in list_of_hooke_curves: + #playlist_element=newdoc.createElement("curve") + playlist_element=newdoc.createElement("element") + top_element.appendChild(playlist_element) + for key in item.__dict__: + if not (key in self.hidden_attributes): + newdoc.createAttribute(key) + playlist_element.setAttribute(key,str(item.__dict__[key])) + + self.playlist=newdoc + + def load(self,filename): + ''' + loads a playlist file + ''' + myplay=file(filename) + self.playpath=filename + + #the following 3 lines are needed to strip newlines. otherwise, since newlines + #are XML elements too (why?), the parser would read them (and re-save them, multiplying + #newlines...) + #yes, I'm an XML n00b + the_file=myplay.read() + the_file_lines=the_file.split('\n') + the_file=''.join(the_file_lines) + + self.playlist=xml.dom.minidom.parseString(the_file) + + #inner parsing functions + def handlePlaylist(playlist): + list_of_files=playlist.getElementsByTagName("element") + generics=playlist.getElementsByTagName("generics") + return handleFiles(list_of_files), handleGenerics(generics) + + def handleGenerics(generics): + generics_dict={} + if len(generics)==0: + return generics_dict + + for attribute in generics[0].attributes.keys(): + generics_dict[attribute]=generics[0].getAttribute(attribute) + return generics_dict + + def handleFiles(list_of_files): + new_playlist=[] + for myfile in list_of_files: + #rebuild a data structure from the xml attributes + the_curve=lhc.HookeCurve(myfile.getAttribute('path')) + for attribute in myfile.attributes.keys(): #extract attributes for the single curve + the_curve.__dict__[attribute]=myfile.getAttribute(attribute) + new_playlist.append(the_curve) + + return new_playlist #this is the true thing returned at the end of this function...(FIXME: clarity) + + return handlePlaylist(self.playlist) + + + def save(self,output_filename): + ''' + saves the playlist in a XML file. + ''' + outfile=file(output_filename,'w') + self.playlist.writexml(outfile,indent='\n') + outfile.close() + + +class HookeConfig: + ''' + Handling of Hooke configuration file + + Mostly based on the simple-yet-useful examples of the Python Library Reference + about xml.dom.minidom + + FIXME: starting to look a mess, should require refactoring + ''' + + def __init__(self): + self.config={} + self.config['plugins']=[] + self.config['drivers']=[] + self.config['plotmanips']=[] + + def load_config(self, filename): + myconfig=file(filename) + + #the following 3 lines are needed to strip newlines. otherwise, since newlines + #are XML elements too, the parser would read them (and re-save them, multiplying + #newlines...) + #yes, I'm an XML n00b + the_file=myconfig.read() + the_file_lines=the_file.split('\n') + the_file=''.join(the_file_lines) + + self.config_tree=xml.dom.minidom.parseString(the_file) + + def getText(nodelist): + #take the text from a nodelist + #from Python Library Reference 13.7.2 + rc = '' + for node in nodelist: + if node.nodeType == node.TEXT_NODE: + rc += node.data + return rc + + def handleConfig(config): + display_elements=config.getElementsByTagName("display") + plugins_elements=config.getElementsByTagName("plugins") + drivers_elements=config.getElementsByTagName("drivers") + workdir_elements=config.getElementsByTagName("workdir") + plotmanip_elements=config.getElementsByTagName("plotmanips") + handleDisplay(display_elements) + handlePlugins(plugins_elements) + handleDrivers(drivers_elements) + handleWorkdir(workdir_elements) + handlePlotmanip(plotmanip_elements) + + def handleDisplay(display_elements): + for element in display_elements: + for attribute in element.attributes.keys(): + self.config[attribute]=element.getAttribute(attribute) + + def handlePlugins(plugins): + for plugin in plugins[0].childNodes: + try: + self.config['plugins'].append(str(plugin.tagName)) + except: #if we allow fancy formatting of xml, there is a text node, so tagName fails for it... + pass + #FIXME: code duplication + def handleDrivers(drivers): + for driver in drivers[0].childNodes: + try: + self.config['drivers'].append(str(driver.tagName)) + except: #if we allow fancy formatting of xml, there is a text node, so tagName fails for it... + pass + + def handlePlotmanip(plotmanips): + for plotmanip in plotmanips[0].childNodes: + try: + self.config['plotmanips'].append(str(plotmanip.tagName)) + except: #if we allow fancy formatting of xml, there is a text node, so tagName fails for it... + pass + + def handleWorkdir(workdir): + wdir=getText(workdir[0].childNodes) + self.config['workdir']=wdir.strip() + + handleConfig(self.config_tree) + #making items in the dictionary more machine-readable + for item in self.config.keys(): + try: + self.config[item]=float(self.config[item]) + except TypeError: #we are dealing with a list, probably. keep it this way. + try: + self.config[item]=eval(self.config[item]) + except: #not a list, not a tuple, probably a string? + pass + except ValueError: #if we can't get it to a number, it must be None or a string + if string.lower(self.config[item])=='none': + self.config[item]=None + else: + pass + + return self.config + + + def save_config(self, config_filename): + print 'Not Implemented.' + pass + + +class ClickedPoint: + ''' + this class defines what a clicked point on the curve plot is + ''' + def __init__(self): + + self.is_marker=None #boolean ; decides if it is a marker + self.is_line_edge=None #boolean ; decides if it is the edge of a line (unused) + self.absolute_coords=(None,None) #(float,float) ; the absolute coordinates of the clicked point on the graph + self.graph_coords=(None,None) #(float,float) ; the coordinates of the plot that are nearest in X to the clicked point + self.index=None #integer ; the index of the clicked point with respect to the vector selected + self.dest=None #0 or 1 ; 0=top plot 1=bottom plot + + + def find_graph_coords(self, xvector, yvector): + ''' + Given a clicked point on the plot, finds the nearest point in the dataset (in X) that + corresponds to the clicked point. + ''' + + #Ye Olde sorting algorithm... + index=0 + best_index=0 + best_diff=10^9 #FIXME:hope we never go over this magic number, doing best_diff=max(xvector)-min(xvector) can be better... + for point in xvector: + diff=abs(point-self.absolute_coords[0]) + if diff1: + for index in range(len(peaks_location)-1): + if peaks_location[index+1]-peaks_location[index] < seedouble: + temp_location=peaks_location[:index]+peaks_location[index+1:] + if temp_location != []: + peaks_location=temp_location + + return peaks_location,peaks_size \ No newline at end of file diff --git a/macro.py b/macro.py new file mode 100644 index 0000000..acb25ce --- /dev/null +++ b/macro.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python + +''' +COMMAND MACRO PLUGIN FOR HOOKE + +Records, saves and executes batches of commands +(c)Alberto Gomez-Casado 2008 +''' + +import libhookecurve as lhc +import os.path + +class macroCommands: + + currentmacro=[] + pause=0 + auxprompt=[] + macrodir=[] + + + def _plug_init(self): + self.currentmacro=[] + self.auxprompt=self.prompt + self.macrodir=os.curdir + if not os.path.exists(os.path.join(self.macrodir,'macros')): + try: + os.mkdir('macros') + self.macrodir=os.path.join(self.macrodir,'macros') + except: + print 'Warning: cannot create macros folder.' + print 'Probably you do not have permissions in your Hooke folder, use macro at your own risk.' + + def collect(self): + + print 'Enter STOP / PAUSE to go back to normal mode\nUNDO to remove last command' + line=[] + while not(line=='STOP' or line=='PAUSE'): + line=raw_input('hooke (macroREC): ') + if line=='PAUSE': + self.pause=1 + self.prompt='hooke (macroPAUSE): ' + break + if line=='STOP': + self.prompt=self.auxprompt + self.do_recordmacro('stop') + break + if line=='UNDO': + self.currentmacro.pop() + continue + param=line.split() + + #FIXME check if accessing param[2] when it doesnt exist breaks something + if param[0] =='export': + exportline=param[0]+' __curve__ ' + if len(param)==3: + exportline=exportline+param[2] + self.currentmacro.append(exportline) + self.onecmd(line) + continue + + if param[0] =='txt': + exportline=param[0] + if len(param)==3: + exportline=exportline+' '+param[2] + exportline=exportline+'__curve__' + self.currentmacro.append(exportline) + self.onecmd(line) + continue + + self.onecmd(line) + + self.currentmacro.append(line) + + + def do_recordmacro(self, args): + '''RECORDMACRO + Stores input commands to create script files + ------- + Syntax: recordmacro [start / stop] + If a macro is currently paused start resumes recording + ''' + + + if len(args)==0: + args='start' + + if args=='stop': + self.pause=0 + self.prompt=self.auxprompt + if len(self.currentmacro) != 0: + answer=raw_input('Do you want to save this macro? ') + if answer[0].lower() == 'y': + self.do_savemacro('') + else: + print 'Macro discarded' + self.currentmacro=[] + else: + print 'Macro was empty' + + if args=='start': + + if self.pause==1: + self.pause=0 + self.collect() + else: + if len(self.currentmacro) != 0: + answer=raw_input('Another macro is already beign recorded\nDo you want to save it?') + if answer[0].lower() == 'y': + self.do_savemacro('') + else: + print 'Old macro discarded, you can start recording the new one' + + self.currentmacro=[] + self.collect() + + + def do_savemacro(self, macroname): + + '''SAVEMACRO + Saves previously recorded macro into a script file for future use + ------- + Syntax: savemacro [macroname] + If no macroname is supplied one will be interactively asked + ''' + + saved_ok=0 + if self.currentmacro==None: + print 'No macro is being recorded!' + return 0 + if len(macroname)==0: + macroname=raw_input('Enter new macro name: ') + if len(macroname) == 0: + print 'Invalid name' + + macroname=os.path.join(self.macrodir,macroname+'.hkm') + if os.path.exists(macroname): + overwrite=raw_input('That name is in use, overwrite?') + if overwrite[0].lower()!='y': + print 'Cancelled save' + return 0 + txtfile=open(macroname,'w+') + self.currentmacro='\n'.join(self.currentmacro) + txtfile.write(self.currentmacro) + txtfile.close() + print 'Saved on '+macroname + self.currentmacro=[] + + def do_execmacro (self, args): + + '''EXECMACRO + Loads a macro and executes it over current curve / playlist + ----- + Syntax: execmacro macroname [playlist] [v] + + macroname.hkm should be present at [hooke]/macros directory + By default the macro will be executed over current curve + passing 'playlist' word as second argument executes macroname + over all curves + By default curve(s) will be processed silently, passing 'v' + as second/third argument will print each command that is + executed + + Note that macros applied to playlists should end by export + commands so the processed curves are not lost + ''' + verbose=0 + cycle=0 + curve=None + + if len(self.currentmacro) != 0: + print 'Warning!: you are calling a macro while recording other' + if len(args) == 0: + print 'You must provide a macro name' + return 0 + args=args.split() + + print 'args ' + ' '.join(args) + + if len(args)>1: + if args[1] == 'playlist': + cycle=1 + print 'Remember! macros applied over playlists should include export orders' + if len(args)>2 and args[2] == 'v': + verbose=1 + else: + if args[1] == 'v': + verbose=1 + print cycle + print verbose + + macropath=os.path.join(self.macrodir,args[0]+'.hkm') + if not os.path.exists(macropath): + print 'Could not find a macro with that name' + return 0 + txtfile=open(macropath) + if cycle ==1: + #print self.current_list + for item in self.current_list: + self.current=item + self.do_plot(0) + + for command in txtfile: + + if verbose==1: + print 'Executing command '+command + testcmd=command.split() + w=0 + for word in testcmd: + if word=='__curve__': + testcmd[w]=os.path.splitext(os.path.basename(item.path))[0] + w=w+1 + self.onecmd(' '.join(testcmd)) + self.current.curve.close_all() + txtfile.seek(0) + else: + for command in txtfile: + testcmd=command.split() + w=0 + if verbose==1: + print 'Executing command '+command + for word in testcmd: + if word=='__curve__': + testcmd[w]=os.path.splitext(os.path.basename(self.current.path))[0] + w=w+1 + self.onecmd(' '.join(testcmd)) + + + + + + diff --git a/massanalysis.py b/massanalysis.py new file mode 100644 index 0000000..e3d3830 --- /dev/null +++ b/massanalysis.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python + +''' +massanalysis.py + +Global analysis of force curves with various parameters + +Requires: +libpeakspot.py +flatfilts.py +''' + + +import libpeakspot as lps +import libhookecurve as lhc +import libhooke as lh +import numpy as np + +import csv + +class massanalysisCommands: + + def _plug_init(self): + self.mass_variables={} + self.interesting_variables=['curve','firstpeak_distance','lastpeak_distance','Npeaks','median_distance','mean_distance'] + self._clean_data() + + def _clean_data(self): + for variable in self.interesting_variables: + self.mass_variables[variable]=[] + + def peak_position_from_contact(self, item, locations): + ''' + calculates X distance of a peak from the contact point + ''' + item.identify(self.drivers) + + real_positions=[] + cut_index=self.find_contact_point() + + #we assume the first is the plot with the force curve + plot=item.curve.default_plots()[0] + xret=plot.vectors[1][0] + + start_x=xret[cut_index] + + real_positions=[abs((xret[index])-(start_x)) for index in locations] + #close all open files + item.curve.close_all() + #needed to avoid *big* memory leaks! + del item.curve + del item + return real_positions + + def do_maplist(self,args): + ''' + MAPLIST + (flatfilts.py) + ---- + pass + ''' + self._clean_data() #if we recall it, clean previous data! + min_deviation=self.convfilt_config['mindeviation'] + + + c=0 + for item in self.current_list: + try: + peak_location,peak_size=self.exec_has_peaks(item, min_deviation) + real_positions=self.peak_position_from_contact(item, peak_location) + + self.mass_variables['Npeaks'].append(len(peak_location)) + + if len(peak_location) > 1: + self.mass_variables['firstpeak_distance'].append(min(real_positions)) + self.mass_variables['lastpeak_distance'].append(max(real_positions)) + + distancepeaks=[] + for index in range(len(real_positions)-1): + distancepeaks.append(real_positions[index+1]-real_positions[index]) + else: + self.mass_variables['firstpeak_distance'].append(0) + self.mass_variables['lastpeak_distance'].append(0) + + if len(peak_location) > 2: + self.mass_variables['median_distance'].append(np.median(distancepeaks)) + self.mass_variables['mean_distance'].append(np.mean(distancepeaks)) + else: + self.mass_variables['median_distance'].append(0) + self.mass_variables['mean_distance'].append(0) + + print 'curve',c + except SyntaxError: + print 'curve',c,'not mapped' + pass + + c+=1 + + def do_plotmap(self,args): + ''' + ''' + args=args.split() + if len(args)>1: + x=self.mass_variables[args[0]] + y=self.mass_variables[args[1]] + else: + print 'Give me two arguments between those:' + print self.interesting_variables + return + + scattermap=lhc.PlotObject() + scattermap.vectors=[[]] + scattermap.vectors[0].append(x) + scattermap.vectors[0].append(y) + + scattermap.units=[args[0],args[1]] + scattermap.styles=['scatter'] + scattermap.destination=1 + + self._send_plot([scattermap]) + + def do_savemaps(self,args): + ''' + args=filename + ''' + + ''' + def csv_write_cols(data, f): + + #from Bruno Desthuillers on comp.lang.python + + writer = csv.writer(f) + keys = data.keys() + writer.writerow(dict(zip(keys,keys))) + for row in zip(*data.values()): + writer.writerow(dict(zip(keys, row))) + ''' + + f=open(args,'wb') + lh.csv_write_dictionary(f,self.mass_variables) + f.close() + + \ No newline at end of file diff --git a/picoforce.py b/picoforce.py new file mode 100755 index 0000000..7c0e926 --- /dev/null +++ b/picoforce.py @@ -0,0 +1,546 @@ +#!/usr/bin/env python + +''' +libpicoforce.py + +Library for interpreting Picoforce force spectroscopy files. + +Copyright (C) 2006 Massimo Sandal (University of Bologna, Italy). + +This program is released under the GNU General Public License version 2. +''' + +import re, struct +from scipy import arange + +import libhookecurve as lhc + +__version__='0.0.0.20080404' + + +class DataChunk(list): + #Dummy class to provide ext and ret methods to the data list. + + def ext(self): + halflen=(len(self)/2) + return self[0:halflen] + + def ret(self): + halflen=(len(self)/2) + return self[halflen:] + +class picoforceDriver(lhc.Driver): + + #Construction and other special methods + + def __init__(self,filename): + ''' + constructor method + ''' + + self.textfile=file(filename) + self.binfile=file(filename,'rb') + + #The 0,1,2 data chunks are: + #0: D (vs T) + #1: Z (vs T) + #2: D (vs Z) + + + self.filepath=filename + self.debug=False + + self.filetype='picoforce' + self.experiment='smfs' + + + #Hidden methods. These are meant to be used only by API functions. If needed, however, + #they can be called just like API methods. + + def _get_samples_line(self): + ''' + Gets the samples per line parameters in the file, to understand trigger behaviour. + ''' + self.textfile.seek(0) + + samps_expr=re.compile(".*Samps") + + samps_values=[] + for line in self.textfile.readlines(): + if samps_expr.match(line): + try: + samps=int(line.split()[2]) #the third word splitted is the offset (in bytes) + samps_values.append(samps) + except: + pass + + #We raise a flag for the fact we meet an offset, otherwise we would take spurious data length arguments. + + return int(samps_values[0]) + + def _get_chunk_coordinates(self): + ''' + This method gets the coordinates (offset and length) of a data chunk in our + Picoforce file. + + It returns a list containing two tuples: + the first element of each tuple is the data_offset, the second is the corresponding + data size. + + In near future probably each chunk will get its own data structure, with + offset, size, type, etc. + ''' + self.textfile.seek(0) + + offset_expr=re.compile(".*Data offset") + length_expr=re.compile(".*Data length") + + data_offsets=[] + data_sizes=[] + flag_offset=0 + + for line in self.textfile.readlines(): + + if offset_expr.match(line): + offset=int(line.split()[2]) #the third word splitted is the offset (in bytes) + data_offsets.append(offset) + #We raise a flag for the fact we meet an offset, otherwise we would take spurious data length arguments. + flag_offset=1 + + #same for the data length + if length_expr.match(line) and flag_offset: + size=int(line.split()[2]) + data_sizes.append(size) + #Put down the offset flag until the next offset is met. + flag_offset=0 + + return zip(data_offsets,data_sizes) + + def _get_data_chunk(self,whichchunk): + ''' + reads a data chunk and converts it in 16bit signed int. + ''' + offset,size=self._get_chunk_coordinates()[whichchunk] + + + self.binfile.seek(offset) + raw_chunk=self.binfile.read(size) + + my_chunk=[] + for data_position in range(0,len(raw_chunk),2): + data_unit_bytes=raw_chunk[data_position:data_position+2] + #The unpack function converts 2-bytes in a signed int ('h'). + #we use output[0] because unpack returns a 1-value tuple, and we want the number only + data_unit=struct.unpack('h',data_unit_bytes)[0] + my_chunk.append(data_unit) + + return DataChunk(my_chunk) + + def _get_Zscan_info(self,index): + ''' + gets the Z scan informations needed to interpret the data chunk. + These info come from the general section, BEFORE individual chunk headers. + + By itself, the function will parse for three parameters. + (index) that tells the function what to return when called by + exposed API methods. + index=0 : returns Zscan_V_LSB + index=1 : returns Zscan_V_start + index=2 : returns Zscan_V_size + ''' + self.textfile.seek(0) + + ciaoforcelist_expr=re.compile(".*Ciao force") + zscanstart_expr=re.compile(".*@Z scan start") + zscansize_expr=re.compile(".*@Z scan size") + + ciaoforce_flag=0 + theline=0 + for line in self.textfile.readlines(): + if ciaoforcelist_expr.match(line): + ciaoforce_flag=1 #raise a flag: zscanstart and zscansize params to read are later + + if ciaoforce_flag and zscanstart_expr.match(line): + raw_Zscanstart_line=line.split() + + if ciaoforce_flag and zscansize_expr.match(line): + raw_Zscansize_line=line.split() + + Zscanstart_line=[] + Zscansize_line=[] + for itemscanstart,itemscansize in zip(raw_Zscanstart_line,raw_Zscansize_line): + Zscanstart_line.append(itemscanstart.strip('[]()')) + Zscansize_line.append(itemscansize.strip('[]()')) + + Zscan_V_LSB=float(Zscanstart_line[6]) + Zscan_V_start=float(Zscanstart_line[8]) + Zscan_V_size=float(Zscansize_line[8]) + + return (Zscan_V_LSB,Zscan_V_start,Zscan_V_size)[index] + + def _get_Z_magnify_scale(self,whichchunk): + ''' + gets Z scale and Z magnify + Here we get Z scale/magnify from the 'whichchunk' only. + whichchunk=1,2,3 + TODO: make it coherent with data_chunks syntaxis (0,1,2) + + In future, should we divide the *file* itself into chunk descriptions and gain + true chunk data structures? + ''' + self.textfile.seek(0) + + z_scale_expr=re.compile(".*@4:Z scale") + z_magnify_expr=re.compile(".*@Z magnify") + + ramp_size_expr=re.compile(".*@4:Ramp size") + ramp_offset_expr=re.compile(".*@4:Ramp offset") + + occurrences=0 + found_right=0 + + + for line in self.textfile.readlines(): + if z_magnify_expr.match(line): + occurrences+=1 + if occurrences==whichchunk: + found_right=1 + raw_z_magnify_expression=line.split() + else: + found_right=0 + + if found_right and z_scale_expr.match(line): + raw_z_scale_expression=line.split() + if found_right and ramp_size_expr.match(line): + raw_ramp_size_expression=line.split() + if found_right and ramp_offset_expr.match(line): + raw_ramp_offset_expression=line.split() + + return float(raw_z_magnify_expression[5]),float(raw_z_scale_expression[7]), float(raw_ramp_size_expression[7]), float(raw_ramp_offset_expression[7]), float(raw_z_scale_expression[5][1:]) + + + #Exposed APIs. + #These are the methods that are meant to be called from external apps. + + def LSB_to_volt(self,chunknum,voltrange=20): + ''' + Converts the LSB data of a given chunk (chunknum=0,1,2) in volts. + First step to get the deflection and the force. + + SYNTAXIS: + item.LSB_to_volt(chunknum, [voltrange]) + + The voltrange is by default set to 20 V. + ''' + return DataChunk([((float(lsb)/65535)*voltrange) for lsb in self.data_chunks[chunknum]]) + + def LSB_to_deflection(self,chunknum,deflsensitivity=None,voltrange=20): + ''' + Converts the LSB data in deflection (meters). + + SYNTAXIS: + item.LSB_to_deflection(chunknum, [deflection sensitivity], [voltrange]) + + chunknum is the chunk you want to parse (0,1,2) + + The deflection sensitivity by default is the one parsed from the file. + The voltrange is by default set to 20 V. + ''' + if deflsensitivity is None: + deflsensitivity=self.get_deflection_sensitivity() + + lsbvolt=self.LSB_to_volt(chunknum) + return DataChunk([volt*deflsensitivity for volt in lsbvolt]) + + def deflection(self): + ''' + Get the actual force curve deflection. + ''' + deflchunk= self.LSB_to_deflection(2) + return deflchunk.ext(),deflchunk.ret() + + def LSB_to_force(self,chunknum=2,Kspring=None,voltrange=20): + ''' + Converts the LSB data (of deflection) in force (newtons). + + SYNTAXIS: + item.LSB_to_force([chunknum], [spring constant], [voltrange]) + + chunknum is the chunk you want to parse (0,1,2). The chunk used is by default 2. + The spring constant by default is the one parsed from the file. + The voltrange is by default set to 20 V. + ''' + if Kspring is None: + Kspring=self.get_spring_constant() + + lsbdefl=self.LSB_to_deflection(chunknum) + return DataChunk([(meter*Kspring) for meter in lsbdefl]) + + def get_Zscan_V_start(self): + return self._get_Zscan_info(1) + + def get_Zscan_V_size(self): + return self._get_Zscan_info(2) + + def get_Z_scan_sensitivity(self): + ''' + gets Z sensitivity + ''' + self.textfile.seek(0) + + z_sensitivity_expr=re.compile(".*@Sens. Zsens") + + for line in self.textfile.readlines(): + if z_sensitivity_expr.match(line): + z_sensitivity=float(line.split()[3]) + #return it in SI units (that is: m/V, not nm/V) + return z_sensitivity*(10**(-9)) + + def get_Z_magnify(self,whichchunk): + ''' + Gets the Z magnify factor. Normally it is 1, unknown exact use as of 2006-01-13 + ''' + return self._get_Z_magnify_scale(whichchunk)[0] + + def get_Z_scale(self,whichchunk): + ''' + Gets the Z scale. + ''' + return self._get_Z_magnify_scale(whichchunk)[1] + + def get_ramp_size(self,whichchunk): + ''' + Gets the -user defined- ramp size + ''' + return self._get_Z_magnify_scale(whichchunk)[2] + + def get_ramp_offset(self,whichchunk): + ''' + Gets the ramp offset + ''' + return self._get_Z_magnify_scale(whichchunk)[3] + + def get_Z_scale_LSB(self,whichchunk): + ''' + Gets the LSB-to-volt conversion factor of the Z data. + (so called hard-scale in the Nanoscope documentation) + + ''' + return self._get_Z_magnify_scale(whichchunk)[4] + + def get_deflection_sensitivity(self): + ''' + gets deflection sensitivity + ''' + self.textfile.seek(0) + + def_sensitivity_expr=re.compile(".*@Sens. DeflSens") + + for line in self.textfile.readlines(): + if def_sensitivity_expr.match(line): + def_sensitivity=float(line.split()[3]) + break + #return it in SI units (that is: m/V, not nm/V) + return def_sensitivity*(10**(-9)) + + def get_spring_constant(self): + ''' + gets spring constant. + We actually find *three* spring constant values, one for each data chunk (F/t, Z/t, F/z). + They are normally all equal, but we retain all three for future... + ''' + self.textfile.seek(0) + + springconstant_expr=re.compile(".*Spring Constant") + + constants=[] + + for line in self.textfile.readlines(): + if springconstant_expr.match(line): + constants.append(float(line.split()[2])) + + return constants[0] + + def get_Zsensorsens(self): + ''' + gets Zsensorsens for Z data. + + This is the sensitivity needed to convert the LSB data in nanometers for the Z-vs-T data chunk. + ''' + self.textfile.seek(0) + + zsensorsens_expr=re.compile(".*Sens. ZSensorSens") + + for line in self.textfile.readlines(): + if zsensorsens_expr.match(line): + zsensorsens_raw_expression=line.split() + #we must take only first occurrence, so we exit from the cycle immediately + break + + return (float(zsensorsens_raw_expression[3]))*(10**(-9)) + + def Z_data(self): + ''' + returns converted ext and ret Z curves. + They're on the second chunk (Z vs t). + ''' + #Zmagnify_zt=self.get_Z_magnify(2) + #Zscale_zt=self.get_Z_scale(2) + Zlsb_zt=self.get_Z_scale_LSB(2) + #rampsize_zt=self.get_ramp_size(2) + #rampoffset_zt=self.get_ramp_offset(2) + zsensorsens=self.get_Zsensorsens() + + ''' + The magic formula that converts the Z data is: + + meters = LSB * V_lsb_conversion_factor * ZSensorSens + ''' + + #z_curves=[item*Zlsb_zt*zsensorsens for item in self.data_chunks[1].pair['ext']],[item*Zlsb_zt*zsensorsens for item in self.data_chunks[1].pair['ret']] + z_curves=[item*Zlsb_zt*zsensorsens for item in self.data_chunks[1].ext()],[item*Zlsb_zt*zsensorsens for item in self.data_chunks[1].ret()] + z_curves=[DataChunk(item) for item in z_curves] + return z_curves + + def Z_extremes(self): + ''' + returns the extremes of the Z values + ''' + zcurves=self.Z_data() + z_extremes={} + z_extremes['ext']=zcurves[0][0],zcurves[0][-1] + z_extremes['ret']=zcurves[1][0],zcurves[1][-1] + + return z_extremes + + def Z_step(self): + ''' + returns the calculated step between the Z values + ''' + zrange={} + zpoints={} + + z_extremes=self.Z_extremes() + + zrange['ext']=abs(z_extremes['ext'][0]-z_extremes['ext'][1]) + zrange['ret']=abs(z_extremes['ret'][0]-z_extremes['ret'][1]) + + #We must take 1 from the calculated zpoints, or when I use the arange function gives me a point more + #with the step. That is, if I have 1000 points, and I use arange(start,stop,step), I have 1001 points... + #For cleanness, solution should really be when using arange, but oh well... + zpoints['ext']=len(self.Z_data()[0])-1 + zpoints['ret']=len(self.Z_data()[1])-1 + #this syntax must become coherent!! + return (zrange['ext']/zpoints['ext']),(zrange['ret']/zpoints['ret']) + + def Z_domains(self): + ''' + returns the Z domains on which to plot the force data. + + The Z domains are returned as a single long DataChunk() extended list. The extension and retraction part + can be extracted using ext() and ret() methods. + ''' + x1step=self.Z_step()[0] + x2step=self.Z_step()[1] + + try: + xext=arange(self.Z_extremes()['ext'][0],self.Z_extremes()['ext'][1],-x1step) + xret=arange(self.Z_extremes()['ret'][0],self.Z_extremes()['ret'][1],-x2step) + except: + xext=arange(0,1) + xret=arange(0,1) + print 'picoforce.py: Warning. xext, xret domains cannot be extracted.' + + if not (len(xext)==len(xret)): + xext=xret + if self.debug: + #print warning + print "picoforce.py: Warning. Extension and retraction domains have different sizes." + print "length extension: ", len(xext) + print "length retraction: ", len(xret) + print "You cannot trust the resulting curve." + print "Until a solution is found, I substitute the ext domain with the ret domain. Sorry." + + + return DataChunk(xext.tolist()+xret.tolist()) + + def Z_scan_size(self): + return self.get_Zscan_V_size()*self.get_Z_scan_sensitivity() + + def Z_start(self): + return self.get_Zscan_V_start()*self.get_Z_scan_sensitivity() + + def ramp_size(self,whichchunk): + ''' + to be implemented if needed + ''' + raise "Not implemented yet." + + + def ramp_offset(self,whichchunk): + ''' + to be implemented if needed + ''' + raise "Not implemented yet." + + def detriggerize(self, forcext): + ''' + Cuts away the trigger-induced s**t on the extension curve. + DEPRECATED + cutindex=2 + startvalue=forcext[0] + + for index in range(len(forcext)-1,2,-2): + if forcext[index]>startvalue: + cutindex=index + else: + break + + return cutindex + ''' + return 0 + + def is_me(self): + ''' + self-identification of file type magic + ''' + curve_file=file(self.filepath) + header=curve_file.read(30) + curve_file.close() + + if header[2:17] == 'Force file list': #header of a picoforce file + self.data_chunks=[self._get_data_chunk(num) for num in [0,1,2]] + return True + else: + return False + + def close_all(self): + ''' + Explicitly closes all files + ''' + self.textfile.close() + self.binfile.close() + + def default_plots(self): + ''' + creates the default PlotObject + ''' + + + force=self.LSB_to_force() + zdomain=self.Z_domains() + + samples=self._get_samples_line() + #cutindex=0 + #cutindex=self.detriggerize(force.ext()) + + main_plot=lhc.PlotObject() + + main_plot.vectors=[[zdomain.ext()[0:samples], force.ext()[0:samples]],[zdomain.ret()[0:samples], force.ret()[0:samples]]] + main_plot.normalize_vectors() + main_plot.units=['meters','newton'] + main_plot.destination=0 + main_plot.title=self.filepath + + + return [main_plot] \ No newline at end of file diff --git a/procplots.py b/procplots.py new file mode 100755 index 0000000..f1cc0a3 --- /dev/null +++ b/procplots.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python + +''' +PROCPLOTS +Processed plots plugin for force curves. + +Licensed under the GNU GPL version 2 +''' +from libhooke import WX_GOOD +import wxversion +wxversion.select(WX_GOOD) + +import wx +import libhookecurve as lhc +import numpy as np +import scipy as sp +import scipy.signal +import copy + +class procplotsCommands: + + def _plug_init(self): + pass + + def do_derivplot(self,args): + ''' + DERIVPLOT + (procplots.py plugin) + Plots the derivate (actually, the discrete differentiation) of the current force curve + --------- + Syntax: derivplot + ''' + dplot=self.derivplot_curves() + plot_graph=self.list_of_events['plot_graph'] + wx.PostEvent(self.frame,plot_graph(plots=[dplot])) + + def derivplot_curves(self): + ''' + do_derivplot helper function + + create derivate plot curves for force curves. + ''' + dplot=lhc.PlotObject() + dplot.vectors=[] + + for vector in self.plots[0].vectors: + dplot.vectors.append([]) + dplot.vectors[-1].append(vector[0][:-1]) + dplot.vectors[-1].append(np.diff(vector[1])) + + dplot.destination=1 + dplot.units=self.plots[0].units + + return dplot + + def do_subtplot(self,args): + ''' + SUBTPLOT + (procplots.py plugin) + Plots the difference between ret and ext current curve + ------- + Syntax: subtplot + ''' + #FIXME: sub_filter and sub_order must be args + + if len(self.plots[0].vectors) != 2: + print 'This command only works on a curve with two different plots.' + pass + + outplot=self.subtract_curves(sub_order=1) + + plot_graph=self.list_of_events['plot_graph'] + wx.PostEvent(self.frame,plot_graph(plots=[outplot])) + + def subtract_curves(self, sub_order): + ''' + subtracts the extension from the retraction + ''' + xext=self.plots[0].vectors[0][0] + yext=self.plots[0].vectors[0][1] + xret=self.plots[0].vectors[1][0] + yret=self.plots[0].vectors[1][1] + + #we want the same number of points + maxpoints_tot=min(len(xext),len(xret)) + xext=xext[0:maxpoints_tot] + yext=yext[0:maxpoints_tot] + xret=xret[0:maxpoints_tot] + yret=yret[0:maxpoints_tot] + + if sub_order: + ydiff=[yretval-yextval for yretval,yextval in zip(yret,yext)] + else: #reverse subtraction (not sure it's useful, but...) + ydiff=[yextval-yretval for yextval,yretval in zip(yext,yret)] + + outplot=copy.deepcopy(self.plots[0]) + outplot.vectors[0][0], outplot.vectors[1][0] = xext,xret #FIXME: if I use xret, it is not correct! + outplot.vectors[1][1]=ydiff + outplot.vectors[0][1]=[0 for item in outplot.vectors[1][0]] + + return outplot + + +#-----PLOT MANIPULATORS + def plotmanip_median(self, plot, current, customvalue=None): + ''' + does the median of the y values of a plot + ''' + if customvalue: + median_filter=customvalue + else: + median_filter=self.config['medfilt'] + + if median_filter==0: + return plot + + if float(median_filter)/2 == int(median_filter)/2: + median_filter+=1 + + nplots=len(plot.vectors) + c=0 + while cstartvalue: + cutindex=index + else: + break + + plot.vectors[0][0]=plot.vectors[0][0][:cutindex] + plot.vectors[0][1]=plot.vectors[0][1][:cutindex] + + return plot + ''' + + + +#FFT--------------------------- + def fft_plot(self, vector): + ''' + calculates the fast Fourier transform for the selected vector in the plot + ''' + fftplot=lhc.PlotObject() + fftplot.vectors=[[]] + + fftlen=len(vector)/2 #need just 1/2 of length + fftplot.vectors[-1].append(np.arange(1,fftlen).tolist()) + + try: + fftplot.vectors[-1].append(abs(np.fft(vector)[1:fftlen]).tolist()) + except TypeError: #we take care of newer NumPy (1.0.x) + fftplot.vectors[-1].append(abs(np.fft.fft(vector)[1:fftlen]).tolist()) + + + fftplot.destination=1 + + + return fftplot + + + def do_fft(self,args): + ''' + FFT + (procplots.py plugin) + Plots the fast Fourier transform of the selected plot + --- + Syntax: fft [top,bottom] [select] [0,1...] + + By default, fft performs the Fourier transform on all the 0-th data set on the + top plot. + + [top,bottom]: which plot is the data set to fft (default: top) + [select]: you pick up two points on the plot and fft only the segment between + [0,1,...]: which data set on the selected plot you want to fft (default: 0) + ''' + + #args parsing + #whatplot = plot to fft + #whatset = set to fft in the plot + select=('select' in args) + if 'top' in args: + whatplot=0 + elif 'bottom' in args: + whatplot=1 + else: + whatplot=0 + whatset=0 + for arg in args: + try: + whatset=int(arg) + except ValueError: + pass + + if select: + points=self._measure_N_points(N=2, whatset=whatset) + boundaries=[points[0].index, points[1].index] + boundaries.sort() + y_to_fft=self.plots[whatplot].vectors[whatset][1][boundaries[0]:boundaries[1]] #y + else: + y_to_fft=self.plots[whatplot].vectors[whatset][1] #y + + fftplot=self.fft_plot(y_to_fft) + fftplot.units=['frequency', 'power'] + plot_graph=self.list_of_events['plot_graph'] + wx.PostEvent(self.frame,plot_graph(plots=[fftplot])) + \ No newline at end of file diff --git a/superimpose.py b/superimpose.py new file mode 100644 index 0000000..fdd0a33 --- /dev/null +++ b/superimpose.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python + +from libhooke import WX_GOOD +import wxversion +wxversion.select(WX_GOOD) +from wx import PostEvent + +import libhookecurve as lhc +from numpy import arange, mean + +class superimposeCommands: + + def _plug_init(self): + self.imposed=[] + + def do_selimpose(self,args): + ''' + SELIMPOSE (superimpose.py plugin) + Hand-selects the curve portion to superimpose + ''' + #fixme: set to superimpose should be in args + + if args=='clear': + self.imposed=[] + return + + current_set=1 + + points=self._measure_two_points() + boundaries=[points[0].index, points[1].index] + boundaries.sort() + + theplot=self.plots[0] + #append the selected section + self.imposed.append([]) + self.imposed[-1].append(theplot.vectors[1][0][boundaries[0]:boundaries[1]]) #x + self.imposed[-1].append(theplot.vectors[1][1][boundaries[0]:boundaries[1]]) #y + + #align X first point + self.imposed[-1][0] = [item-self.imposed[-1][0][0] for item in self.imposed[-1][0]] + #align Y first point + self.imposed[-1][1] = [item-self.imposed[-1][1][0] for item in self.imposed[-1][1]] + + def do_plotimpose(self,args): + ''' + PLOTIMPOSE (sumperimpose.py plugin) + plots superimposed curves + ''' + imposed_object=lhc.PlotObject() + imposed_object.vectors=self.imposed + print 'Plotting',len(imposed_object.vectors),'imposed curves' + + imposed_object.normalize_vectors() + + imposed_object.units=self.plots[0].units + imposed_object.title='Imposed curves' + imposed_object.destination=1 + + plot_graph=self.list_of_events['plot_graph'] + PostEvent(self.frame,plot_graph(plots=[imposed_object])) + + def do_plotavgimpose(self,args): + ''' + PLOTAVGIMPOSE (superimpose.py plugin) + Plots the average of superimposed curves using a running window + ''' + step=(-5*(10**-10)) + #find extension of each superimposed curve + min_x=[] + for curve in self.imposed: + min_x.append(min(curve[0])) + + #find minimum extension + min_ext_limit=max(min_x) + + x_avg=arange(step,min_ext_limit,step) + y_avg=[] + for value in x_avg: + to_avg=[] + for curve in self.imposed: + for xvalue, yvalue in zip(curve[0],curve[1]): + if xvalue >= (value+step) and xvalue <= (value-step): + to_avg.append(yvalue) + y_avg.append(mean(to_avg)) + + print 'len x len y' + print len(x_avg), len(y_avg) + print y_avg + + avg_object=lhc.PlotObject() + avg_object.vectors=[[x_avg, y_avg]] + avg_object.normalize_vectors() + avg_object.units=self.plots[0].units + avg_object.title="Average curve" + avg_object.destination=1 + + plot_graph=self.list_of_events['plot_graph'] + PostEvent(self.frame,plot_graph(plots=[avg_object])) \ No newline at end of file diff --git a/tutorial.py b/tutorial.py new file mode 100755 index 0000000..cb24d68 --- /dev/null +++ b/tutorial.py @@ -0,0 +1,544 @@ +#!/usr/bin/env python + +''' +TUTORIAL PLUGIN FOR HOOKE + +This plugin contains example commands to teach how to write an Hooke plugin, including description of main Hooke +internals. +(c)Massimo Sandal 2007 +''' + +import libhookecurve as lhc + +import numpy as np + +''' +SYNTAX OF DATA TYPE DECLARATION: + type = type of object + [ type ] = list containing objects of type + {typekey:typearg} = dictionary with keys of type typekey and args of type typearg + ( type ) = tuple containing objects of type +''' + + +class tutorialCommands: + ''' + Here we define the class containing all the Hooke commands we want to define + in the plugin. + + Notice the class name!! + The syntax is filenameCommands. That is, if your plugin is pluggy.py, your class + name is pluggyCommands. + + Otherwise, the class will be ignored by Hooke. + ''' + + def _plug_init(self): + ''' + This is the plugin initialization. + When Hooke starts and the plugin is loaded, this function is executed. + If there is something you need to do when Hooke starts, code it in this function. + ''' + print 'I am the Tutorial plugin initialization!' + + #Here we initialize a local configuration variable; see plotmanip_absvalue() for explanation. + self.config['tutorial_absvalue']=0 + pass + + def do_nothing(self,args): + ''' + This is a boring but working example of an actual Hooke command. + A Hooke command is a function of the xxxxCommands class, which is ALWAYS defined + this way: + + def do_nameofcommand(self,args) + + *do_ is needed to make Hooke understand this function is a command + *nameofcommand is how the command will be called in the Hooke command line. + *self is, well, self + *args is ALWAYS needed (otherwise Hooke will crash executing the command). We will see + later what args is. + + Note that if you now start Hooke with this plugin activated and you type in the Hooke command + line "help nothing" you will see this very text as output. So the help of a command is a + string comment below the function definition, like this one. + + Commands usually return None. + ''' + print 'I am a Hooke command. I do nothing.' + + def do_printargs(self,args): + ''' + This command prints the args you give to it. + args is always a string, that contains everything you write after the command. + So if you issue "mycommand blah blah 12345" args is "blah blah 12345". + + Again, args is needed in the definition even if your command does not use it. + ''' + print 'You gave me those args: '+args + + def help_tutorial(self): + ''' + This is a help function. + If you want a help function for something that is not a command, you can write a help + function like this. Calling "help tutorial" will execute this function. + ''' + print 'You called help_tutorial()' + + def do_environment(self,args): + ''' + This plugin contains a panoramic of the Hooke command line environment variables, + and prints their current value. + ''' + + '''self.current_list + TYPE: [ libhookecurve.HookeCurve ], len=variable + contains the actual playlist of Hooke curve objects. + Each HookeCurve object represents a reference to a data file. + We will see later in detail how do they work. + ''' + print 'current_list length:',len(self.current_list) + print 'current_list 0th:',self.current_list[0] + + '''self.pointer + TYPE: int + contains the index of + the current curve in the playlist + ''' + print 'pointer: ',self.pointer + + '''self.current + TYPE: libhookecurve.HookeCurve + contains the current curve displayed. + We will see later how it works. + ''' + print 'current:',self.current + + '''self.plots + TYPE: [ libhookecurve.PlotObject ], len=1,2 + contains the current default plots. + Each PlotObject contains all info needed to display + the plot: apart from the data vectors, the title, destination + etc. + Usually self.plots[0] is the default topmost plot, self.plots[1] is the + accessory bottom plot. + ''' + print 'plots:',self.plots + + '''self.config + TYPE: { string:anything } + contains the current Hooke configuration variables, in form of a dictionary. + ''' + print 'config:',self.config + + '''self.plotmanip + TYPE: [ function ] + Contains the ordered plot manipulation functions. + These functions are called to modify the default plot by default before it is plotted. + self.plots contains the plot passed through the plot manipulators. + We will see it better later. + *YOU SHOULD NEVER MODIFY THAT* + ''' + print 'plotmanip: ',self.plotmanip + + '''self.drivers + TYPE: [ class ] + Contains the plot reading drivers. + *YOU SHOULD NEVER MODIFY THAT* + ''' + print 'drivers: ',self.drivers + + '''self.frame + TYPE: wx.Frame + Contains the wx Frame of the GUI. + ***NEVER, EVER TOUCH THAT.*** + ''' + print 'frame: ',self.frame + + '''self.list_of_events + TYPE: { string:wx.Event } + Contains the wx.Events to communicate with the GUI. + Usually not necessary to use it, unless you want + to create a GUI plugin. + ''' + print 'list of events:',self.list_of_events + + '''self.events_from_gui + TYPE: Queue.Queue + Contains the Queue where data from the GUI is put. + Usually not necessary to use it, unless you want + to create a GUI plugin. + ''' + print 'events from gui:',self.events_from_gui + + '''self.playlist_saved + TYPE: Int (0/1) ; Boolean + Flag that tells if the playlist has been saved or not. + ''' + print 'playlist saved:',self.playlist_saved + + '''self.playlist_name + TYPE: string + Name of current playlist + ''' + print 'playlist name:',self.playlist_name + + '''self.notes_saved + TYPE: Int (0/1) ; Boolean + Flag that tells if the playlist has been saved or not. + ''' + print 'notes saved:',self.notes_saved + + + def do_myfirstplot(self,args): + ''' + In this function, we see how to create a PlotObject and send it to the screen. + ***Read the code of PlotObject in libhookecurve.py before!***. + ''' + + #We generate some interesting data to plot for this example. + xdata1=np.arange(-5,5,0.1) + xdata2=np.arange(-5,5,0.1) + ydata1=[item**2 for item in xdata1] + ydata2=[item**3 for item in xdata2] + + #Create the object. + #The PlotObject class lives in the libhookecurve library. + myplot=lhc.PlotObject() + myplot.vectors=[[],[]] #Decide we will have two data sets in this plot + ''' + The *data* of the plot live in the .vectors list. + + plot.vectors is a multidimensional array: + plot.vectors[0]=set1 + plot.vectors[1]=set2 + plot.vectors[2]=sett3 + etc. + + 2 curves in a x,y plot are: + [[[x1],[y1]],[[x2],[y2]]] + for example: + x1 y1 x2 y2 + [[[1,2,3,4],[10,20,30,40]],[[3,6,9,12],[30,60,90,120]]] + x1 = self.vectors[0][0] + y1 = self.vectors[0][1] + x2 = self.vectors[1][0] + y2 = self.vectors[1][1] + ''' + #Pour 0-th dataset into myplot: + myplot.vectors[0].append(xdata1) #...x + myplot.vectors[0].append(ydata1) #...then y + + #Pour 1-st dataset into myplot: + myplot.vectors[1].append(xdata2) #...x + myplot.vectors[1].append(ydata2) #...then y + + #Add units to x and y axes + #units=[string, string] + myplot.units=['x axis','y axis'] + + #Where do we want the plot? 0=top, 1=bottom + myplot.destination=1 + + '''Send it to the GUI. + Note that you *have* to encapsulate it into a list, so you + have to send [myplot], not simply myplot. + + You can also send more two plots at once + self.send_plot([plot1,plot2]) + ''' + self._send_plot([myplot]) + + + def do_myfirstscatter(self,args): + ''' + How to draw a scatter plot. + ''' + #We generate some interesting data to plot for this example. + xdata1=np.arange(-5,5,1) + xdata2=np.arange(-5,5,1) + ydata1=[item**2 for item in xdata1] + ydata2=[item**3 for item in xdata2] + + myplot=lhc.PlotObject() + myplot.vectors=[[],[]] + #Pour 0-th dataset into myplot: + myplot.vectors[0].append(xdata1) #...x + myplot.vectors[0].append(ydata1) #...then y + + #Pour 1-st dataset into myplot: + myplot.vectors[1].append(xdata2) #...x + myplot.vectors[1].append(ydata2) #...then y + + #Add units to x and y axes + myplot.units=['x axis','y axis'] + + #Where do we want the plot? 0=top, 1=bottom + myplot.destination=1 + + '''None=standard line plot + 'scatter'=scatter plot + By default, the styles attribute is an empty list. If you + want to define a scatter plot, you must define all other + plots as None or 'scatter', depending on what you want. + + Here we define the second set to be plotted as scatter, + and the first to be plotted as line. + ''' + myplot.styles=[None,'scatter'] + + self._send_plot([myplot]) + + + def do_clickaround(self,args): + ''' + Here we click two points on the curve and take some parameters from the points + we have clicked. + ''' + + ''' + points = self._measure_N_points(N=Int, whatset=Int) + *N = number of points to measure(1...n) + *whatset = data set to measure (0,1...n) + *points = a list of ClickedPoint objects, one for each point requested + ''' + points=self._measure_N_points(N=2,whatset=1) + print 'You clicked the following points.' + + ''' + These are the absolute coordinates of the + point clicked. + [float, float] = x,y + ''' + print 'Absolute coordinates:' + print points[0].absolute_coords + print points[1].absolute_coords + print + + ''' + These are the coordinates of the points + clicked, remapped on the graph. + Hooke looks at the graph point which X + coordinate is next to the X coordinate of + the point measured, and uses that point + as the actual clicked point. + [float, float] = x,y + ''' + print 'Coordinates on the graph:' + print points[0].graph_coords + print points[1].graph_coords + print + + ''' + These are the indexes of the clicked points + on the dataset vector. + ''' + print 'Index of points on the graph:' + print points[0].index + print points[1].index + + + def help_thedifferentplots(self): + ''' + The *three* different default plots you should be familiar with + in Hooke. + + Each plot contains of course the respective data in their + vectors attribute, so here you learn also which data access for + each situation. + ''' + print ''' + 1. THE RAW, CURRENT PLOTS + + self.current + --- + Contains the current libhookecurve.HookeCurve container object. + A HookeCurve object defines only two default attributes: + + * self.current.path = string + The path of the current displayed curve + + * self.current.curve = libhookecurve.Driver + The curve object. This is not only generated by the driver, + this IS a driver instance in itself. + This means that in self.current.curve you can access the + specific driver APIs, if you know them. + + And defines only one method: + * self.current.identify() + Fills in the self.current.curve object. + See in the cycling tutorial. + + ***** + The REAL curve data actually lives in: + --- + * self.current.curve.default_plots() = [ libhooke.PlotObject ] + Contains the raw PlotObject-s, as "spitted out" by the driver, without any + intervention. + This is as close to the raw data as Hooke gets. + + One or two plots can be spit out; they are always enclosed in a list. + ***** + + Methods of self.current.curve are: + --- + + * self.current.curve.is_me() + (Used by identify() only.) + + * self.current.curve.close_all() + Closes all driver open files; see the cycling tutorial. + ''' + + print ''' + 2. THE PROCESSED, DEFAULT PLOT + + The plot that is spitted out by the driver is *not* the usual default plot + that is displayed by calling "plot" at the Hooke prompt. + + This is because the raw, driver-generated plot is usually *processed* by so called + *plot processing* functions. We will see in the tutorial how to define + them. + + For example, in force spectroscopy force curves, raw data are automatically corrected + for deflection. Other data can be, say, filtered by default. + + The default plots are accessible in + self.plots = [ libhooke.PlotObject ] + + self.plots[0] is usually the topmost plot + self.plots[1] is usually the bottom plot (if present) + ''' + + print ''' + 3. THE PLOT DISPLAYED RIGHT NOW. + + Sometimes the plots you are displaying *right now* is different from the previous + two. You may have a fit trace, you may have issued some command that spits out + a custom plot and you want to rework that, whatever. + + You can obtain in any moment the plot currently displayed by Hooke by issuing + + PlotObject = self._get_displayed_plot(dest) + * dest = Int (0/1) + dest=0 : top plot + dest=1 : bottom plot + ''' + + + def do_cycling(self,args): + ''' + Here we cycle through our playlist and print some info on the curves we find. + Cycling through the playlist needs a bit of care to avoid memory leaks and dangling + open files... + + Look at the source code for more information. + ''' + + def things_when_cycling(item): + ''' + We encapsulate here everything has to open the actual curve file. + By doing it all here, we avoid to do acrobacies when deleting objects etc. + in the main loop: we do the dirty stuff here. + ''' + + ''' + identify() + + This method looks for the correct driver in self.drivers to use; + and puts the curve content in the .curve attribute. + Basically, until identify() is called, the HookeCurve object + is just an empty shell. When identify() is called (usually by + the Hooke plot routine), the HookeCurve object is "filled" with + the actual curve. + ''' + + item.identify(self.drivers) + + ''' + After the identify(), item.curve contains the curve, and item.curve.default_plots() behaves exactly like + self.current.curve.default_plots() -but for the given item. + ''' + itplot=item.curve.default_plots() + + print 'length of X1 vector:',len(itplot[0].vectors[0][0]) #just to show something + + ''' + The following three lines are a magic spell you HAVE to do + before closing the function. + (Otherwise you will be plagued by unpredicatable, system-dependent bugs.) + ''' + item.curve.close_all() #Avoid open files dangling + del item.curve #Avoid memory leaks + del item #Just be paranoid. Don't ask. + + return + + + c=0 + for item in self.current_list: + print 'Looking at curve ',c,'of',len(self.current_list) + things_when_cycling(item) + c+=1 + + return + + + + def plotmanip_absvalue(self, plot, current, customvalue=None): + ''' + This function defines a PLOT MANIPULATOR. + A plot manipulator is a function that takes a plot in input, does something to the plot + and returns the modified plot in output. + The function, once plugged, gets automatically called everytime self.plots is updated + + For example, in force spectroscopy force curves, raw data are automatically corrected + for deflection. Other data can be, say, filtered by default. + + To create and activate a plot manipulator you have to: + * Write a function (like this) which name starts with "plotmanip_" (just like commands + start with "do_") + * The function must support four arguments: + self : (as usual) + plot : a plot object + current : (usually not used, deprecated) + customvalue=None : a variable containing custom value(s) you need for your plot manipulators. + * The function must return a plot object. + * Add an entry in hooke.conf: if your function is "plotmanip_something" you will have + to add in the plotmanips section: example + + + + + + + + + Important: Plot manipulators are *in pipe*: each plot manipulator output becomes the input of the next one. + The order in hooke.conf *is the order* in which plot manipulators are connected, so in the example above + we have: + self.current.curve.default_plots() --> detriggerize --> correct --> median --> something --> self.plots + ''' + + ''' + Here we see what is in a configuration variable to enable/disable the plot manipulator as user will using + the Hooke "set" command. + Typing "set tutorial_absvalue 0" disables the plot manipulator; typing "set tutorial_absvalue 1" will enable it. + ''' + if not self.config['tutorial_absvalue']: + return plot + + #We do something to the plot, for demonstration's sake + #If we needed variables, we would have used customvalue. + plot.vectors[0][1]=[abs(i) for i in plot.vectors[0][1]] + plot.vectors[1][1]=[abs(i) for i in plot.vectors[1][1]] + + #Return the plot object. + return plot + + +#TODO IN TUTORIAL: +#how to add lines to an existing plot!! +#peaks +#configuration files +#gui plugins diff --git a/tutorialdriver.py b/tutorialdriver.py new file mode 100644 index 0000000..b16512a --- /dev/null +++ b/tutorialdriver.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python + +''' +tutorialdriver.py + +TUTORIAL DRIVER FOR HOOKE + +Example driver to teach how to write a driver for data types. +(c)Massimo Sandal 2008 +''' + +''' +Here we define a (fake) file format that is read by this driver. The file format is as following: + +TUTORIAL_FILE +PLOT1 +X1 +n1 +n2 +... +nN +Y1 +n1 +n2 +... +nN +X2 +n1 +n2 +.. +nN +Y2 +n1 +n2 +.. +nN +PLOT2 +X1 +... +Y1 +... +X2 +... +Y2 +... +END +that is, two plots with two datasets each. +''' + +import libhookecurve as lhc #We need to import this library to define some essential data types + +class tutorialdriverDriver(lhc.Driver): + ''' + This is a *generic* driver, not a specific force spectroscopy driver. + See the written documentation to see what a force spectroscopy driver must be defined to take into account Hooke facilities. + + Our driver is a class with the name convention nameofthedriverDriver, where "nameofthedriver" is the filename. + The driver must inherit from the parent class lhc.Driver, so the syntax is + class nameofthedriverDriver(lhc.Driver) + ''' + def __init__(self, filename): + ''' + THIS METHOD MUST BE DEFINED. + The __init__ method MUST call the filename, so that it can open the file. + ''' + self.filename=filename #self.filename can always be useful, and should be defined + self.filedata = open(filename,'r') #We open the file + ''' + In this case, we have a data format that is just a list of ASCII values, so we can just divide that in rows, and generate a list + with each item being a row. + Of course if your data files are binary, or follow a different approach, do whatever you need. :) + ''' + self.data = list(self.filedata) + self.filedata.close() #remember to close the file + + '''These are two strings that can be used by Hooke commands/plugins to understand what they are looking at. They have no other + meaning. They have to be somehow defined however - commands often look for those variables. + + self.filetype should contain the name of the exact filetype defined by the driver (so that filetype-specific commands can know + if they're dealing with the correct filetype) + self.experiment should contain instead the type of data involved (for example, various drivers can be used for force-clamp experiments, + but hooke commands could like to know if we're looking at force clamp data, regardless of their origin, and not other + kinds of data) + + Of course, all other variables you like can be defined in the class. + ''' + self.filetype = 'tutorial' + self.experiment = 'generic' + + def is_me(self): + ''' + THIS METHOD MUST BE DEFINED. + RETURNS: Boolean (True or False) + This method must be an heuristic that looks at the file content and decides if the file can be opened by the driver itself. + It returns True if the file opened can be interpreted by the current driver, False otherwise. + Defining this method allows Hooke to understand what kind of files we're looking at automatically. + + We have to re-open/re-close the file here. + ''' + + myfile=open(self.filename, 'r') + headerline=myfile.readlines()[0] #we take the first line + myfile.close() + + ''' + Here, our "magic fingerprint" is the TUTORIAL_FILE header. Of course, depending on the data file, you can have interesting + headers, or patterns, etc. that you can use to guess the data format. What matters is successful recognizing, and returning + a boolean (True/False). + ''' + if headerline[:-1]=='TUTORIAL_FILE': #[:-1], otherwise the return character is included in the line + return True + else: + return False + + def _generate_vectors(self): + ''' + Here we parse the data and generate the raw vectors. This method has only to do with the peculiar file format here, so it's of + no big interest (I just wrote it to present a functional driver). + + Only thing to remember, it can be nice to call methods that are used only "internally" by the driver (or by plugins) with a + "_" prefix, so to have a visual remark. But it's just an advice. + ''' + vectors={'PLOT1':[[],[],[],[]] , 'PLOT2':[[],[],[],[]]} + positions={'X1':0,'Y1':1,'X2':2,'Y2':3} + whatplot='' + pos=0 + for item in self.data: + try: + num=float(item) + vectors[whatplot][pos].append(num) + except ValueError: + if item[:-1]=='PLOT1': + whatplot=item[:-1] + elif item[:-1]=='PLOT2': + whatplot=item[:-1] + elif item[0]=='X' or item[0]=='Y': + pos=positions[item[:-1]] + else: + pass + + return vectors + + def close_all(self): + ''' + THIS METHOD MUST BE DEFINED. + This method is a precaution method that is invoked when cycling to avoid eventually dangling open files. + In this method, all file objects defined in the driver must be closed. + ''' + self.filename.close() + + + def default_plots(self): + ''' + THIS METHOD MUST BE DEFINED. + RETURNS: [ lhc.PlotObject ] or [ lhc.PlotObject, lhc.PlotObject] + + This is the method that returns the plots to Hooke. + It must return a list with *one* or *two* PlotObjects. + + See the libhookecurve.py source code to see how PlotObjects are defined and work in detail. + ''' + gen_vectors=self._generate_vectors() + + #Here we create the main plot PlotObject and initialize its vectors. + main_plot=lhc.PlotObject() + main_plot.vectors=[] + #The same for the other plot. + other_plot=lhc.PlotObject() + other_plot.vectors=[] + + ''' + Now we fill the plot vectors with our data. + set 1 set 2 + The "correct" shape of the vector is [ [[x1,x2,x3...],[y1,y2,y3...]] , [[x1,x2,x3...],[y1,y2,y3...]] ], so we have to put stuff in this way into it. + + The add_set() method takes care of this , just use plot.add_set(x,y). + ''' + main_plot.add_set(gen_vectors['PLOT1'][0],gen_vectors['PLOT1'][1]) + main_plot.add_set(gen_vectors['PLOT1'][2],gen_vectors['PLOT1'][3]) + + other_plot.add_set(gen_vectors['PLOT2'][0],gen_vectors['PLOT2'][1]) + other_plot.add_set(gen_vectors['PLOT2'][2],gen_vectors['PLOT2'][3]) + + ''' + normalize_vectors() trims the vectors, so that if two x/y couples are of different lengths, the latest + points are trimmed (otherwise we have a python error). Always a good idea to run it, to avoid crashes. + ''' + main_plot.normalize_vectors() + other_plot.normalize_vectors() + + ''' + Here we define: + - units: [string, string], define the measure units of X and Y axes + - destination: 0/1 , defines where to plot the plot (0=top, 1=bottom), default=0 + - title: string , the plot title. + + for each plot. + Again, see libhookecurve.py comments for details. + ''' + main_plot.units=['unit of x','unit of y'] + main_plot.destination=0 + main_plot.title=self.filename+' main' + + other_plot.units=['unit of x','unit of y'] + other_plot.destination=1 + other_plot.title=self.filename+' other' + + return [main_plot, other_plot] \ No newline at end of file -- 2.26.2