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