Add support for nosetests multiprocessing plugin.
[sawsim.git] / pysawsim / sawsim.py
1 # Copyright (C) 2010  W. Trevor King <wking@drexel.edu>
2 #
3 # This program is free software: you can redistribute it and/or modify
4 # it under the terms of the GNU General Public License as published by
5 # the Free Software Foundation, either version 3 of the License, or
6 # (at your option) any later version.
7 #
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 # GNU General Public License for more details.
12 #
13 # You should have received a copy of the GNU General Public License
14 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
15 #
16 # The author may be contacted at <wking@drexel.edu> on the Internet, or
17 # write to Trevor King, Drudge's University, Physics Dept., 3141 Chestnut St.,
18 # Philadelphia PA 19104, USA.
19
20
21 """`Seminar` for running `sawsim` and parsing the results.
22 """
23
24 from __future__ import with_statement
25
26 try:
27     from collections import namedtuple
28 except ImportError:  # work around Python < 2.6
29     from ._collections import namedtuple
30 import hashlib
31 from optparse import Option
32 import os
33 import os.path
34 from random import shuffle
35 import shutil
36 from uuid import uuid4
37
38 from . import __version__
39 from .manager import MANAGERS, get_manager, InvokeJob
40
41
42 _multiprocess_can_split_ = True
43 """Allow nosetests to split tests between processes.
44 """
45
46 SAWSIM = 'sawsim'  # os.path.expand(os.path.join('~', 'bin', 'sawsim'))
47 CACHE_DIR = os.path.expanduser(os.path.join('~', '.sawsim-cache'))
48 DEFAULT_PARAM_STRING = (
49     '-s cantilever,hooke,0.05 -N1 '
50     '-s folded,null -N8 '
51     "-s 'unfolded,wlc,{0.39e-9,28e-9}' "
52     "-k 'folded,unfolded,bell,{3.3e-4,0.25e-9}' "
53     '-q folded -v 1e-6')
54
55
56 # `Event` instances represent domain state transitions.
57 Event = namedtuple(
58     typename='Event',
59     field_names=['force', 'initial_state', 'final_state'])
60
61
62 class SawsimRunner (object):
63     """
64     >>> from .manager.thread import ThreadManager
65     >>> m = ThreadManager()
66     >>> sr = SawsimRunner(sawsim='bin/sawsim', manager=m)
67     >>> for run in sr(param_string=DEFAULT_PARAM_STRING, N=2):
68     ...     print 'New run'
69     ...     for i,event in enumerate(run):
70     ...         print i, event  # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
71     New run
72     0 Event(force=..., initial_state='folded', final_state='unfolded')
73     1 Event(force=..., initial_state='folded', final_state='unfolded')
74     2 Event(force=..., initial_state='folded', final_state='unfolded')
75     3 Event(force=..., initial_state='folded', final_state='unfolded')
76     4 Event(force=..., initial_state='folded', final_state='unfolded')
77     5 Event(force=..., initial_state='folded', final_state='unfolded')
78     6 Event(force=..., initial_state='folded', final_state='unfolded')
79     7 Event(force=..., initial_state='folded', final_state='unfolded')
80     New run
81     0 Event(force=..., initial_state='folded', final_state='unfolded')
82     1 Event(force=..., initial_state='folded', final_state='unfolded')
83     2 Event(force=..., initial_state='folded', final_state='unfolded')
84     3 Event(force=..., initial_state='folded', final_state='unfolded')
85     4 Event(force=..., initial_state='folded', final_state='unfolded')
86     5 Event(force=..., initial_state='folded', final_state='unfolded')
87     6 Event(force=..., initial_state='folded', final_state='unfolded')
88     7 Event(force=..., initial_state='folded', final_state='unfolded')
89     >>> m.teardown()
90     """
91
92     optparse_options = [
93         Option('-s','--sawsim', dest='sawsim',
94                metavar='PATH',
95                help='Set sawsim binary (%default).',
96                default=SAWSIM),
97         Option('-p','--params', dest='param_string',
98                metavar='PARAMS',
99                help='Initial params for fitting (%default).',
100                default=DEFAULT_PARAM_STRING),
101         Option('-N', '--number-of-runs', dest='N',
102                metavar='INT', type='int',
103                help='Number of sawsim runs at each point in parameter space (%default).',
104                default=400),
105         Option('-m', '--manager', dest='manager',
106                metavar='STRING',
107                help='Job manager name (one of %s) (%%default).'
108                % (', '.join(MANAGERS)),
109                default=MANAGERS[0]),
110         Option('-C','--use-cache', dest='use_cache',
111                help='Use cached simulations if they exist (vs. running new simulations) (%default)',
112                default=False, action='store_true'),
113         Option('--clean-cache', dest='clean_cache',
114                help='Remove previously cached simulations if they exist (%default)',
115                default=False, action='store_true'),
116         Option('-d','--cache-dir', dest='cache_dir',
117                metavar='STRING',
118                help='Cache directory for sawsim unfolding forces (%default).',
119                default=CACHE_DIR),
120     ]
121
122     def __init__(self, sawsim=None, cache_dir=None,
123                  use_cache=False, clean_cache=False,
124                  manager=None):
125         if sawsim == None:
126             sawsim = SAWSIM
127         self._sawsim = sawsim
128         if cache_dir == None:
129             cache_dir = CACHE_DIR
130         self._cache_dir = cache_dir
131         self._use_cache = use_cache
132         self._clean_cache = clean_cache
133         self._manager = manager
134         self._local_manager = False
135         self._headline = None
136
137     def initialize_from_options(self, options):
138         self._sawsim = options.sawsim
139         self._cache_dir = options.cache_dir
140         self._use_cache = options.use_cache
141         self._clean_cache = options.clean_cache
142         self._manager = get_manager(options.manager)()
143         self._local_manager = True
144         call_params = {}
145         for param in ['param_string', 'N']:
146             try:
147                 call_params[param] = getattr(options, param)
148             except AttributeError:
149                 pass
150         return call_params
151
152     def teardown(self):
153         if self._local_manager == True:
154             self._manager.teardown()
155
156     def __call__(self, param_string, N):
157         """Run `N` simulations and yield `Event` generators for each run.
158
159         Use the `JobManager` instance `manager` for asynchronous job
160         execution.
161
162         If `_use_cache` is `True`, store an array of unfolding forces
163         in `cache_dir` for each simulated pull.  If the cached forces
164         are already present for `param_string`, do not redo the
165         simulation unless `_clean_cache` is `True`.
166         """
167         count = N
168         if self._use_cache == True:
169             d = self._param_cache_dir(param_string)
170             if os.path.exists(d):
171                 if self._clean_cache == True:
172                     shutil.rmtree(d)
173                     self._make_cache(param_string)
174                 else:
175                     for data in self._load_cached_data(param_string):
176                         yield data
177                         count -= 1
178                         if count == 0:
179                             return
180             else:
181                 self._make_cache(param_string)
182
183         jobs = {}
184         for i in range(count):
185             jobs[i] = self._manager.async_invoke(InvokeJob(
186                     target='%s %s' % (self._sawsim, param_string)))
187         complete_jobs = self._manager.wait(
188             [job.id for job in jobs.itervalues()])
189         for i,job in jobs.iteritems():
190             j = complete_jobs[job.id]
191             assert j.status == 0, j.data['error']
192             if self._use_cache == True:
193                 self._cache_run(d, j.data['stdout'])
194             yield self.parse(j.data['stdout'])
195         del(jobs)
196         del(complete_jobs)
197
198     def _param_cache_dir(self, param_string):
199         """
200         >>> s = SawsimRunner()
201         >>> s._param_cache_dir(DEFAULT_PARAM_STRING)  # doctest: +ELLIPSIS
202         '/.../.sawsim-cache/...'
203         """
204         return os.path.join(
205             self._cache_dir, hashlib.sha256(param_string).hexdigest())
206
207     def _make_cache(self, param_string):
208         cache_dir = self._param_cache_dir(param_string)
209         os.makedirs(cache_dir)
210         with open(os.path.join(cache_dir, 'param_string'), 'w') as f:
211             f.write('# version: %s\n%s\n' % (__version__, param_string))
212
213     def _load_cached_data(self, param_string):
214         pcd = self._param_cache_dir(param_string)
215         filenames = os.listdir(pcd)
216         shuffle(filenames)
217         for filename in filenames:
218             if not filename.endswith('.dat'):
219                 continue
220             with open(os.path.join(pcd, filename), 'r') as f:
221                 yield self.parse(f.read())
222
223     def _cache_run(self, cache_dir, stdout):
224         simulation_path = os.path.join(cache_dir, '%s.dat' % uuid4())
225         with open(simulation_path, 'w') as f:
226             f.write(stdout)
227
228     def parse(self, text):
229         """Parse the output of a `sawsim` run.
230     
231         >>> text = '''#Force (N)\\tinitial state\\tFinal state
232         ... 2.90301e-10\\tfolded\\tunfolded
233         ... 2.83948e-10\\tfolded\\tunfolded
234         ... 2.83674e-10\\tfolded\\tunfolded
235         ... 2.48384e-10\\tfolded\\tunfolded
236         ... 2.43033e-10\\tfolded\\tunfolded
237         ... 2.77589e-10\\tfolded\\tunfolded
238         ... 2.85343e-10\\tfolded\\tunfolded
239         ... 2.67796e-10\\tfolded\\tunfolded
240         ... '''
241         >>> sr = SawsimRunner()
242         >>> events = list(sr.parse(text))
243         >>> len(events)
244         8
245         >>> events[0]  # doctest: +ELLIPSIS
246         Event(force=2.9030...e-10, initial_state='folded', final_state='unfolded')
247         >>> sr._headline
248         ['Force (N)', 'initial state', 'Final state']
249         """
250         for line in text.splitlines():
251             line = line.strip()
252             if len(line) == 0:
253                 continue
254             elif line.startswith('#'):
255                 if self._headline == None:
256                     self._headline = line[len('#'):].split('\t')
257                 continue
258             fields = line.split('\t')
259             if len(fields) != 3:
260                 raise ValueError(fields)
261             force,initial_state,final_state = fields
262             yield Event(float(force), initial_state, final_state)
263
264
265 def main(argv=None):
266     """
267     >>> try:
268     ...     main(['--help'])
269     ... except SystemExit, e:
270     ...     pass  # doctest: +ELLIPSIS, +REPORT_UDIFF
271     Usage: ... [options]
272     <BLANKLINE>
273     Options:
274       -h, --help            show this help message and exit
275       -s PATH, --sawsim=PATH
276                             Set sawsim binary (sawsim).
277       ...
278     >>> print e
279     0
280     >>> main(['--sawsim', 'bin/sawsim', '-N', '2'])
281     ... # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
282     #Force (N)  Initial state  Final state
283     ...         folded         unfolded
284     ...         folded         unfolded
285     ...         folded         unfolded
286     ...         folded         unfolded
287     ...
288     ...         folded         unfolded
289     """
290     from optparse import OptionParser
291     import sys
292
293     if argv == None:
294         argv = sys.argv[1:]
295
296     sr = SawsimRunner()
297
298     usage = '%prog [options]'
299     epilog = '\n'.join([
300             'Python wrapper around `sawsim`.  Distribute `N` runs using',
301             'one of the possible job "managers".  Also supports caching',
302             'results to speed future runs.'
303             ])
304     parser = OptionParser(usage, epilog=epilog)
305     for option in sr.optparse_options:
306         parser.add_option(option)
307     
308     options,args = parser.parse_args(argv)
309
310     try:
311         sr_call_params = sr.initialize_from_options(options)
312     
313         first_run = True
314         for run in sr(**sr_call_params):
315             if first_run == True:
316                 first_run = False
317                 run = list(run)  # force iterator evaluation
318                 if sr._headline != None:
319                     print '#%s' % '\t'.join(sr._headline)
320             for event in run:
321                 print '\t'.join([str(x) for x in event])
322     finally:
323         sr.teardown()