4e1b6caecc8abcc6953cace618d6daaab3a8b10f
[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 from copy_reg import dispatch_table
24 from copy import _reconstruct, _copy_dispatch
25 import logging
26 import os.path
27
28 import numpy
29
30 from .command_stack import CommandStack
31
32
33 class NotRecognized (ValueError):
34     def __init__(self, curve):
35         self.__setstate__(curve)
36
37     def __getstate__(self):
38         return self.curve
39
40     def __setstate__(self, data):
41         if isinstance(data, Curve):
42             msg = 'Not a recognizable curve format: %s' % data.path
43             super(NotRecognized, self).__init__(msg)
44             self.curve = data
45
46
47 class Data (numpy.ndarray):
48     """Stores a single, continuous data set.
49
50     Adds :attr:`info` :class:`dict` to the standard :class:`numpy.ndarray`.
51
52     See :mod:`numpy.doc.subclassing` for the peculiarities of
53     subclassing :class:`numpy.ndarray`.
54
55     Examples
56     --------
57
58     >>> d = Data(shape=(3,2), info={'columns':['distance (m)', 'force (N)']})
59     >>> type(d)
60     <class 'hooke.curve.Data'>
61     >>> for i in range(3): # initialize d
62     ...    for j in range(2):
63     ...        d[i,j] = i*10 + j
64     >>> d
65     Data([[  0.,   1.],
66            [ 10.,  11.],
67            [ 20.,  21.]])
68     >>> d.info
69     {'columns': ['distance (m)', 'force (N)']}
70
71     The information gets passed on to slices.
72
73     >>> row_a = d[:,0]
74     >>> row_a
75     Data([  0.,  10.,  20.])
76     >>> row_a.info
77     {'columns': ['distance (m)', 'force (N)']}
78
79     The data-type is also pickleable, to ensure we can move it between
80     processes with :class:`multiprocessing.Queue`\s.
81
82     >>> import pickle
83     >>> s = pickle.dumps(d)
84     >>> z = pickle.loads(s)
85     >>> z
86     Data([[  0.,   1.],
87            [ 10.,  11.],
88            [ 20.,  21.]])
89     >>> z.info
90     {'columns': ['distance (m)', 'force (N)']}
91
92     The data-type is also YAMLable (see :mod:`hooke.util.yaml`).
93
94     >>> import yaml
95     >>> s = yaml.dump(d)
96     >>> print s
97     !hooke.curve.DataInfo
98     columns: [distance (m), force (N)]
99     <BLANKLINE>
100     >>> z = yaml.load(s)
101     >>> z
102     Data([], shape=(0, 0), dtype=float32)
103     """
104     def __new__(subtype, shape, dtype=numpy.float, buffer=None, offset=0,
105                 strides=None, order=None, info=None):
106         """Create the ndarray instance of our type, given the usual
107         input arguments.  This will call the standard ndarray
108         constructor, but return an object of our type.
109         """
110         obj = numpy.ndarray.__new__(
111             subtype, shape, dtype, buffer, offset, strides, order)
112         # add the new attribute to the created instance
113         if info == None:
114             info = {}
115         obj.info = info
116         # Finally, we must return the newly created object:
117         return obj
118
119     def __array_finalize__(self, obj):
120         """Set any extra attributes from the original object when
121         creating a new view object."""
122         # reset the attribute from passed original object
123         self.info = getattr(obj, 'info', {})
124         # We do not need to return anything
125
126     def __reduce__(self):
127         """Collapse an instance for pickling.
128
129         Returns
130         -------
131         reconstruct : callable
132             Called to create the initial version of the object.
133         args : tuple
134             A tuple of arguments for `reconstruct`
135         state : (optional)
136             The state to be passed to __setstate__, if present.
137         iter : iterator (optional)
138             Yielded items will be appended to the reconstructed
139             object.
140         dict : iterator (optional)
141             Yielded (key,value) tuples pushed back onto the
142             reconstructed object.
143         """
144         base_reduce = list(numpy.ndarray.__reduce__(self))
145         # tack our stuff onto ndarray's setstate portion.
146         base_reduce[2] = (base_reduce[2], (self.info,))
147         return tuple(base_reduce)
148
149     def __setstate__(self, state):
150         base_class_state,own_state = state
151         numpy.ndarray.__setstate__(self, base_class_state)
152         self.info, = own_state
153
154
155 class Curve (object):
156     """A grouped set of :class:`Data` runs from the same file with metadata.
157
158     For an approach/retract force spectroscopy experiment, the group
159     would consist of the approach data and the retract data.  Metadata
160     would be the temperature, cantilever spring constant, etc.
161
162     Each :class:`Data` block in :attr:`data` must contain an
163     :attr:`info['name']` setting with a unique (for the parent
164     curve) name identifying the data block.  This allows plugins 
165     and commands to access individual blocks.
166
167     Each curve maintiains a :class:`~hooke.command_stack.CommandStack`
168     (:attr:`command_stack`) listing the commands that have been
169     applied to the `Curve` since loading.
170
171     The data-type is pickleable, to ensure we can move it between
172     processes with :class:`multiprocessing.Queue`\s.
173
174     >>> import pickle
175     >>> import yaml
176     >>> from .engine import CommandMessage
177     >>> c = Curve(path='some/path')
178
179     We add a recursive reference to `c` as you would get from
180     :meth:`hooke.plugin.curve.CurveCommand._add_to_command_stack`.
181
182     >>> c.command_stack.append(CommandMessage('curve info', {'curve':c}))
183
184     >>> s = pickle.dumps(c)
185     >>> z = pickle.loads(s)
186     >>> z
187     <Curve path>
188     >>> z.command_stack
189     [<CommandMessage curve info {curve: <Curve path>}>]
190     >>> z.command_stack[-1].arguments['curve'] == z
191     True
192     >>> print yaml.dump(c)  # doctest: +REPORT_UDIFF
193     &id001 !!python/object:hooke.curve.Curve
194     command_stack: !!python/object/new:hooke.command_stack.CommandStack
195       listitems:
196       - !!python/object:hooke.engine.CommandMessage
197         arguments:
198           curve: *id001
199         command: curve info
200         explicit_user_call: true
201     name: path
202     path: some/path
203     <BLANKLINE>
204
205     However, if we try and serialize the command stack first, we run
206     into `Python issue 1062277`_.
207
208     .. _Python issue 1062277: http://bugs.python.org/issue1062277
209
210     >>> pickle.dumps(c.command_stack)
211     Traceback (most recent call last):
212       ...
213         assert id(obj) not in self.memo
214     AssertionError
215
216     YAML still works, though.
217
218     >>> print yaml.dump(c.command_stack)  # doctest: +REPORT_UDIFF
219     &id001 !!python/object/new:hooke.command_stack.CommandStack
220     listitems:
221     - !!python/object:hooke.engine.CommandMessage
222       arguments:
223         curve: !!python/object:hooke.curve.Curve
224           command_stack: *id001
225           name: path
226           path: some/path
227       command: curve info
228       explicit_user_call: true
229     <BLANKLINE>
230     """
231     def __init__(self, path, info=None):
232         self.__setstate__({'path':path, 'info':info})
233
234     def __str__(self):
235         return str(self.__unicode__())
236
237     def __unicode__(self):
238         return u'<%s %s>' % (self.__class__.__name__, self.name)
239
240     def __repr__(self):
241         return self.__str__()
242
243     def set_path(self, path):
244         if path != None:
245             path = os.path.expanduser(path)
246         self.path = path
247         if self.name == None and path != None:
248             self.name = os.path.basename(path)
249
250     def _setup_default_attrs(self):
251         # .data contains: {name of data: list of data sets [{[x], [y]}]
252         # ._hooke contains a Hooke instance for Curve.load()
253         self._default_attrs = {
254             '_hooke': None,
255             'command_stack': [],
256             'data': None,
257             'driver': None,
258             'info': {},
259             'name': None,
260             'path': None,
261             }
262
263     def __getstate__(self):
264         state = dict(self.__dict__)  # make a copy of the attribute dict.
265         del(state['_hooke'])
266         return state
267
268     def __setstate__(self, state):
269         self._setup_default_attrs()
270         self.__dict__.update(self._default_attrs)
271         if state == True:
272             return
273         self.__dict__.update(state)
274         self.set_path(getattr(self, 'path', None))
275         if self.info in [None, {}]:
276             self.info = {}
277         if type(self.command_stack) == list:
278             self.command_stack = CommandStack()
279
280     def __copy__(self):
281         """Set copy to preserve :attr:`_hooke`.
282
283         :meth:`getstate` drops :attr:`_hooke` for :mod:`pickle` and
284         :mod:`yaml` output, but it should be preserved (but not
285         duplicated) during copies.
286
287         >>> import copy
288         >>> class Hooke (object):
289         ...     pass
290         >>> h = Hooke()
291         >>> d = Data(shape=(3,2), info={'columns':['distance (m)', 'force (N)']})
292         >>> for i in range(3): # initialize d
293         ...    for j in range(2):
294         ...        d[i,j] = i*10 + j
295         >>> c = Curve(None)
296         >>> c.data = [d]
297         >>> c.set_hooke(h)
298         >>> c._hooke  # doctest: +ELLIPSIS
299         <hooke.curve.Hooke object at 0x...>
300         >>> c._hooke == h
301         True
302         >>> c2 = copy.copy(c)
303         >>> c2._hooke  # doctest: +ELLIPSIS
304         <hooke.curve.Hooke object at 0x...>
305         >>> c2._hooke == h
306         True
307         >>> c2.data
308         [Data([[  0.,   1.],
309                [ 10.,  11.],
310                [ 20.,  21.]])]
311         >>> d.info
312         {'columns': ['distance (m)', 'force (N)']}
313         >>> id(c2.data[0]) == id(d)
314         True
315         """
316         copier = _copy_dispatch.get(type(self))
317         if copier:
318             return copier(self)
319         reductor = dispatch_table.get(type(self))
320         if reductor:
321             rv = reductor(self)
322         else:
323             # :class:`object` implements __reduce_ex__, see :pep:`307`.
324             rv = self.__reduce_ex__(2)
325         y = _reconstruct(self, rv, 0)
326         y.set_hooke(self._hooke)
327         return y
328
329     def __deepcopy__(self, memo):
330         """Set deepcopy to preserve :attr:`_hooke`.
331
332         :meth:`getstate` drops :attr:`_hooke` for :mod:`pickle` and
333         :mod:`yaml` output, but it should be preserved (but not
334         duplicated) during copies.
335
336         >>> import copy
337         >>> class Hooke (object):
338         ...     pass
339         >>> h = Hooke()
340         >>> d = Data(shape=(3,2), info={'columns':['distance (m)', 'force (N)']})
341         >>> for i in range(3): # initialize d
342         ...    for j in range(2):
343         ...        d[i,j] = i*10 + j
344         >>> c = Curve(None)
345         >>> c.data = [d]
346         >>> c.set_hooke(h)
347         >>> c._hooke  # doctest: +ELLIPSIS
348         <hooke.curve.Hooke object at 0x...>
349         >>> c._hooke == h
350         True
351         >>> c2 = copy.deepcopy(c)
352         >>> c2._hooke  # doctest: +ELLIPSIS
353         <hooke.curve.Hooke object at 0x...>
354         >>> c2._hooke == h
355         True
356         >>> c2.data
357         [Data([[  0.,   1.],
358                [ 10.,  11.],
359                [ 20.,  21.]])]
360         >>> d.info
361         {'columns': ['distance (m)', 'force (N)']}
362         >>> id(c2.data[0]) == id(d)
363         False
364         """
365         reductor = dispatch_table.get(type(self))
366         if reductor:
367             rv = reductor(self)
368         else:
369             # :class:`object` implements __reduce_ex__, see :pep:`307`.
370             rv = self.__reduce_ex__(2)
371         y = _reconstruct(self, rv, 1, memo)
372         y.set_hooke(self._hooke)
373         return y
374
375     def set_hooke(self, hooke=None):
376         if hooke != None:
377             self._hooke = hooke
378
379     def identify(self, drivers):
380         """Identify the appropriate :class:`hooke.driver.Driver` for
381         the curve file (`.path`).
382         """
383         if 'filetype' in self.info:
384             driver = [d for d in drivers if d.name == self.info['filetype']]
385             if len(driver) == 1:
386                 driver = driver[0]
387                 if driver.is_me(self.path):
388                     self.driver = driver
389                     return
390         for driver in drivers:
391             if driver.is_me(self.path):
392                 self.driver = driver # remember the working driver
393                 return
394         raise NotRecognized(self)
395
396     def load(self, hooke=None):
397         """Use the driver to read the curve into memory.
398
399         Also runs any commands in :attr:`command_stack`.  All
400         arguments are passed through to
401         :meth:`hooke.command_stack.CommandStack.execute`.
402         """
403         self.set_hooke(hooke)
404         log = logging.getLogger('hooke')
405         log.debug('loading curve %s with driver %s' % (self.name, self.driver))
406         data,info = self.driver.read(self.path, self.info)
407         self.data = data
408         for key,value in info.items():
409             self.info[key] = value
410         if self._hooke != None:
411             self.command_stack.execute(self._hooke)
412         elif len(self.command_stack) > 0:
413             log.warn(
414                 'could not execute command stack for %s without Hooke instance'
415                 % self.name)
416
417     def unload(self):
418         """Release memory intensive :attr:`.data`.
419         """
420         log = logging.getLogger('hooke')
421         log.debug('unloading curve %s' % self.name)
422         self.data = None