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