Serialize hooke.experiment.Experiment instances in Curves.
[hooke.git] / hooke / curve.py
1 # Copyright (C) 2010 W. Trevor King <wking@drexel.edu>
2 #
3 # This file is part of Hooke.
4 #
5 # Hooke is free software: you can redistribute it and/or modify it
6 # under the terms of the GNU Lesser General Public License as
7 # published by the Free Software Foundation, either version 3 of the
8 # License, or (at your option) any later version.
9 #
10 # Hooke is distributed in the hope that it will be useful, but WITHOUT
11 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
12 # or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General
13 # Public License for more details.
14 #
15 # You should have received a copy of the GNU Lesser General Public
16 # License along with Hooke.  If not, see
17 # <http://www.gnu.org/licenses/>.
18
19 """The `curve` module provides :class:`Curve` and :class:`Data` for
20 storing force curves.
21 """
22
23 import logging
24 import os.path
25
26 import numpy
27
28 from .command_stack import CommandStack
29 from . import experiment
30
31
32 class NotRecognized (ValueError):
33     def __init__(self, curve):
34         self.__setstate__(curve)
35
36     def __getstate__(self):
37         return self.curve
38
39     def __setstate__(self, data):
40         if isinstance(data, Curve):
41             msg = 'Not a recognizable curve format: %s' % data.path
42             super(NotRecognized, self).__init__(msg)
43             self.curve = data
44
45
46 class Data (numpy.ndarray):
47     """Stores a single, continuous data set.
48
49     Adds :attr:`info` :class:`dict` to the standard :class:`numpy.ndarray`.
50
51     See :mod:`numpy.doc.subclassing` for the peculiarities of
52     subclassing :class:`numpy.ndarray`.
53
54     Examples
55     --------
56
57     >>> d = Data(shape=(3,2), info={'columns':['distance (m)', 'force (N)']})
58     >>> type(d)
59     <class 'hooke.curve.Data'>
60     >>> for i in range(3): # initialize d
61     ...    for j in range(2):
62     ...        d[i,j] = i*10 + j
63     >>> d
64     Data([[  0.,   1.],
65            [ 10.,  11.],
66            [ 20.,  21.]])
67     >>> d.info
68     {'columns': ['distance (m)', 'force (N)']}
69
70     The information gets passed on to slices.
71
72     >>> row_a = d[:,0]
73     >>> row_a
74     Data([  0.,  10.,  20.])
75     >>> row_a.info
76     {'columns': ['distance (m)', 'force (N)']}
77
78     The data-type is also pickleable, to ensure we can move it between
79     processes with :class:`multiprocessing.Queue`\s.
80
81     >>> import pickle
82     >>> s = pickle.dumps(d)
83     >>> z = pickle.loads(s)
84     >>> z
85     Data([[  0.,   1.],
86            [ 10.,  11.],
87            [ 20.,  21.]])
88     >>> z.info
89     {'columns': ['distance (m)', 'force (N)']}
90     """
91     def __new__(subtype, shape, dtype=numpy.float, buffer=None, offset=0,
92                 strides=None, order=None, info=None):
93         """Create the ndarray instance of our type, given the usual
94         input arguments.  This will call the standard ndarray
95         constructor, but return an object of our type.
96         """
97         obj = numpy.ndarray.__new__(
98             subtype, shape, dtype, buffer, offset, strides, order)
99         # add the new attribute to the created instance
100         if info == None:
101             info = {}
102         obj.info = info
103         # Finally, we must return the newly created object:
104         return obj
105
106     def __array_finalize__(self, obj):
107         """Set any extra attributes from the original object when
108         creating a new view object."""
109         # reset the attribute from passed original object
110         self.info = getattr(obj, 'info', {})
111         # We do not need to return anything
112
113     def __reduce__(self):
114         """Collapse an instance for pickling.
115
116         Returns
117         -------
118         reconstruct : callable
119             Called to create the initial version of the object.
120         args : tuple
121             A tuple of arguments for `reconstruct`
122         state : (optional)
123             The state to be passed to __setstate__, if present.
124         iter : iterator (optional)
125             Yielded items will be appended to the reconstructed
126             object.
127         dict : iterator (optional)
128             Yielded (key,value) tuples pushed back onto the
129             reconstructed object.
130         """
131         base_reduce = list(numpy.ndarray.__reduce__(self))
132         # tack our stuff onto ndarray's setstate portion.
133         base_reduce[2] = (base_reduce[2], (self.info,))
134         return tuple(base_reduce)
135
136     def __setstate__(self, state):
137         base_class_state,own_state = state
138         numpy.ndarray.__setstate__(self, base_class_state)
139         self.info, = own_state
140
141
142 class Curve (object):
143     """A grouped set of :class:`Data` runs from the same file with metadata.
144
145     For an approach/retract force spectroscopy experiment, the group
146     would consist of the approach data and the retract data.  Metadata
147     would be the temperature, cantilever spring constant, etc.
148
149     Two important :attr:`info` settings are `filetype` and
150     `experiment`.  These are two strings that can be used by Hooke
151     commands/plugins to understand what they are looking at.
152
153     * :attr:`info['filetype']` should contain the name of the exact
154       filetype defined by the driver (so that filetype-speciofic
155       commands can know if they're dealing with the correct filetype).
156     * :attr:`info['experiment']` should contain an instance of a
157       :class:`hooke.experiment.Experiment` subclass to identify the
158       experiment type.  For example, various
159       :class:`hooke.driver.Driver`\s can read in force-clamp data, but
160       Hooke commands could like to know if they're looking at force
161       clamp data, regardless of their origin.
162
163     Another important attribute is :attr:`command_stack`, which holds
164     a :class:`~hooke.command_stack.CommandStack` listing the commands
165     that have been applied to the `Curve` since loading.
166     """
167     def __init__(self, path, info=None):
168         #the data dictionary contains: {name of data: list of data sets [{[x], [y]}]
169         self.name = None
170         self.set_path(path)
171         self.driver = None
172         self.data = None
173         if info == None:
174             info = {}
175         self.info = info
176         self.command_stack = CommandStack()
177         self._hooke = None  # Hooke instance for Curve.load()
178
179     def __str__(self):
180         return str(self.__unicode__())
181
182     def __unicode__(self):
183         return u'<%s %s>' % (self.__class__.__name__, self.name)
184
185     def __repr__(self):
186         return self.__str__()
187
188     def set_path(self, path):
189         self.path = path
190         if self.name == None and path != None:
191             self.name = os.path.basename(path)
192
193     def __getstate__(self):
194         state = dict(self.__dict__)
195         del(state['_hooke'])
196         dc = state['command_stack']
197         if hasattr(dc, '__getstate__'):
198             state['command_stack'] = dc.__getstate__()
199         if self.info.get('experiment', None) != None:
200             e = self.info['experiment']
201             assert isinstance(e, experiment.Experiment)
202             # HACK? require Experiment classes to be defined in the
203             # experiment module.
204             self.info['experiment'] = e.__class__.__name__
205         return state
206
207     def __setstate__(self, state):
208         self.name = self._hooke = None
209         for key,value in state.items():
210             if key == 'path':
211                 self.set_path(value)
212                 continue
213             elif key == 'info':
214                 if 'experiment' not in value:
215                     value['experiment'] = None
216                 else:
217                     # HACK? require Experiment classes to be defined in the
218                     # experiment module.
219                     cls = getattr(experiment, value['experiment'])
220                     value['experiment'] = cls()
221             elif key == 'command_stack':
222                 v = CommandStack()
223                 v.__setstate__(value)
224                 value = v
225             setattr(self, key, value)
226
227     def set_hooke(self, hooke=None):
228         if hooke != None:
229             self._hooke = hooke
230
231     def identify(self, drivers):
232         """Identify the appropriate :class:`hooke.driver.Driver` for
233         the curve file (`.path`).
234         """
235         if 'filetype' in self.info:
236             driver = [d for d in drivers if d.name == self.info['filetype']]
237             if len(driver) == 1:
238                 driver = driver[0]
239                 if driver.is_me(self.path):
240                     self.driver = driver
241                     return
242         for driver in drivers:
243             if driver.is_me(self.path):
244                 self.driver = driver # remember the working driver
245                 return
246         raise NotRecognized(self)
247
248     def load(self, hooke=None):
249         """Use the driver to read the curve into memory.
250
251         Also runs any commands in :attr:`command_stack`.  All
252         arguments are passed through to
253         :meth:`hooke.command_stack.CommandStack.execute`.
254         """
255         self.set_hooke(hooke)
256         log = logging.getLogger('hooke')
257         log.debug('loading curve %s with driver %s' % (self.name, self.driver))
258         data,info = self.driver.read(self.path, self.info)
259         self.data = data
260         for key,value in info.items():
261             self.info[key] = value
262         if self._hooke != None:
263             self.command_stack.execute(self._hooke)
264         elif len(self.command_stack) > 0:
265             log.warn(
266                 'could not execute command stack for %s without Hooke instance'
267                 % self.name)
268
269     def unload(self):
270         """Release memory intensive :attr:`.data`.
271         """
272         log = logging.getLogger('hooke')
273         log.debug('unloading curve %s' % self.name)
274         self.data = None