source: sasmodels/sasmodels/compare.py @ 48462b0

core_shell_microgelscostrafo411magnetic_modelticket-1257-vesicle-productticket_1156ticket_1265_superballticket_822_more_unit_tests
Last change on this file since 48462b0 was 48462b0, checked in by Paul Kienzle <pkienzle@…>, 7 years ago

tuned random model generation for even more models

  • Property mode set to 100755
File size: 46.0 KB
Line 
1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3"""
4Program to compare models using different compute engines.
5
6This program lets you compare results between OpenCL and DLL versions
7of the code and between precision (half, fast, single, double, quad),
8where fast precision is single precision using native functions for
9trig, etc., and may not be completely IEEE 754 compliant.  This lets
10make sure that the model calculations are stable, or if you need to
11tag the model as double precision only.
12
13Run using ./compare.sh (Linux, Mac) or compare.bat (Windows) in the
14sasmodels root to see the command line options.
15
16Note that there is no way within sasmodels to select between an
17OpenCL CPU device and a GPU device, but you can do so by setting the
18PYOPENCL_CTX environment variable ahead of time.  Start a python
19interpreter and enter::
20
21    import pyopencl as cl
22    cl.create_some_context()
23
24This will prompt you to select from the available OpenCL devices
25and tell you which string to use for the PYOPENCL_CTX variable.
26On Windows you will need to remove the quotes.
27"""
28
29from __future__ import print_function
30
31import sys
32import os
33import math
34import datetime
35import traceback
36import re
37
38import numpy as np  # type: ignore
39
40from . import core
41from . import kerneldll
42from . import exception
43from .data import plot_theory, empty_data1D, empty_data2D, load_data
44from .direct_model import DirectModel
45from .convert import revert_name, revert_pars, constrain_new_to_old
46from .generate import FLOAT_RE
47
48try:
49    from typing import Optional, Dict, Any, Callable, Tuple
50except Exception:
51    pass
52else:
53    from .modelinfo import ModelInfo, Parameter, ParameterSet
54    from .data import Data
55    Calculator = Callable[[float], np.ndarray]
56
57USAGE = """
58usage: compare.py model N1 N2 [options...] [key=val]
59
60Compare the speed and value for a model between the SasView original and the
61sasmodels rewrite.
62
63model or model1,model2 are the names of the models to compare (see below).
64N1 is the number of times to run sasmodels (default=1).
65N2 is the number times to run sasview (default=1).
66
67Options (* for default):
68
69    -plot*/-noplot plots or suppress the plot of the model
70    -lowq*/-midq/-highq/-exq use q values up to 0.05, 0.2, 1.0, 10.0
71    -nq=128 sets the number of Q points in the data set
72    -zero indicates that q=0 should be included
73    -1d*/-2d computes 1d or 2d data
74    -preset*/-random[=seed] preset or random parameters
75    -mono*/-poly force monodisperse or allow polydisperse demo parameters
76    -magnetic/-nonmagnetic* suppress magnetism
77    -cutoff=1e-5* cutoff value for including a point in polydispersity
78    -pars/-nopars* prints the parameter set or not
79    -abs/-rel* plot relative or absolute error
80    -linear/-log*/-q4 intensity scaling
81    -hist/-nohist* plot histogram of relative error
82    -res=0 sets the resolution width dQ/Q if calculating with resolution
83    -accuracy=Low accuracy of the resolution calculation Low, Mid, High, Xhigh
84    -edit starts the parameter explorer
85    -default/-demo* use demo vs default parameters
86    -help/-html shows the model docs instead of running the model
87    -title="note" adds note to the plot title, after the model name
88    -data="path" uses q, dq from the data file
89
90Any two calculation engines can be selected for comparison:
91
92    -single/-double/-half/-fast sets an OpenCL calculation engine
93    -single!/-double!/-quad! sets an OpenMP calculation engine
94    -sasview sets the sasview calculation engine
95
96The default is -single -double.  Note that the interpretation of quad
97precision depends on architecture, and may vary from 64-bit to 128-bit,
98with 80-bit floats being common (1e-19 precision).
99
100Key=value pairs allow you to set specific values for the model parameters.
101Key=value1,value2 to compare different values of the same parameter.
102value can be an expression including other parameters
103"""
104
105# Update docs with command line usage string.   This is separate from the usual
106# doc string so that we can display it at run time if there is an error.
107# lin
108__doc__ = (__doc__  # pylint: disable=redefined-builtin
109           + """
110Program description
111-------------------
112
113"""
114           + USAGE)
115
116kerneldll.ALLOW_SINGLE_PRECISION_DLLS = True
117
118# list of math functions for use in evaluating parameters
119MATH = dict((k,getattr(math, k)) for k in dir(math) if not k.startswith('_'))
120
121# CRUFT python 2.6
122if not hasattr(datetime.timedelta, 'total_seconds'):
123    def delay(dt):
124        """Return number date-time delta as number seconds"""
125        return dt.days * 86400 + dt.seconds + 1e-6 * dt.microseconds
126else:
127    def delay(dt):
128        """Return number date-time delta as number seconds"""
129        return dt.total_seconds()
130
131
132class push_seed(object):
133    """
134    Set the seed value for the random number generator.
135
136    When used in a with statement, the random number generator state is
137    restored after the with statement is complete.
138
139    :Parameters:
140
141    *seed* : int or array_like, optional
142        Seed for RandomState
143
144    :Example:
145
146    Seed can be used directly to set the seed::
147
148        >>> from numpy.random import randint
149        >>> push_seed(24)
150        <...push_seed object at...>
151        >>> print(randint(0,1000000,3))
152        [242082    899 211136]
153
154    Seed can also be used in a with statement, which sets the random
155    number generator state for the enclosed computations and restores
156    it to the previous state on completion::
157
158        >>> with push_seed(24):
159        ...    print(randint(0,1000000,3))
160        [242082    899 211136]
161
162    Using nested contexts, we can demonstrate that state is indeed
163    restored after the block completes::
164
165        >>> with push_seed(24):
166        ...    print(randint(0,1000000))
167        ...    with push_seed(24):
168        ...        print(randint(0,1000000,3))
169        ...    print(randint(0,1000000))
170        242082
171        [242082    899 211136]
172        899
173
174    The restore step is protected against exceptions in the block::
175
176        >>> with push_seed(24):
177        ...    print(randint(0,1000000))
178        ...    try:
179        ...        with push_seed(24):
180        ...            print(randint(0,1000000,3))
181        ...            raise Exception()
182        ...    except Exception:
183        ...        print("Exception raised")
184        ...    print(randint(0,1000000))
185        242082
186        [242082    899 211136]
187        Exception raised
188        899
189    """
190    def __init__(self, seed=None):
191        # type: (Optional[int]) -> None
192        self._state = np.random.get_state()
193        np.random.seed(seed)
194
195    def __enter__(self):
196        # type: () -> None
197        pass
198
199    def __exit__(self, exc_type, exc_value, traceback):
200        # type: (Any, BaseException, Any) -> None
201        # TODO: better typing for __exit__ method
202        np.random.set_state(self._state)
203
204def tic():
205    # type: () -> Callable[[], float]
206    """
207    Timer function.
208
209    Use "toc=tic()" to start the clock and "toc()" to measure
210    a time interval.
211    """
212    then = datetime.datetime.now()
213    return lambda: delay(datetime.datetime.now() - then)
214
215
216def set_beam_stop(data, radius, outer=None):
217    # type: (Data, float, float) -> None
218    """
219    Add a beam stop of the given *radius*.  If *outer*, make an annulus.
220
221    Note: this function does not require sasview
222    """
223    if hasattr(data, 'qx_data'):
224        q = np.sqrt(data.qx_data**2 + data.qy_data**2)
225        data.mask = (q < radius)
226        if outer is not None:
227            data.mask |= (q >= outer)
228    else:
229        data.mask = (data.x < radius)
230        if outer is not None:
231            data.mask |= (data.x >= outer)
232
233
234def parameter_range(p, v):
235    # type: (str, float) -> Tuple[float, float]
236    """
237    Choose a parameter range based on parameter name and initial value.
238    """
239    # process the polydispersity options
240    if p.endswith('_pd_n'):
241        return 0., 100.
242    elif p.endswith('_pd_nsigma'):
243        return 0., 5.
244    elif p.endswith('_pd_type'):
245        raise ValueError("Cannot return a range for a string value")
246    elif any(s in p for s in ('theta', 'phi', 'psi')):
247        # orientation in [-180,180], orientation pd in [0,45]
248        if p.endswith('_pd'):
249            return 0., 45.
250        else:
251            return -180., 180.
252    elif p.endswith('_pd'):
253        return 0., 1.
254    elif 'sld' in p:
255        return -0.5, 10.
256    elif p == 'background':
257        return 0., 10.
258    elif p == 'scale':
259        return 0., 1.e3
260    elif v < 0.:
261        return 2.*v, -2.*v
262    else:
263        return 0., (2.*v if v > 0. else 1.)
264
265
266def _randomize_one(model_info, name, value):
267    # type: (ModelInfo, str, float) -> float
268    # type: (ModelInfo, str, str) -> str
269    """
270    Randomize a single parameter.
271    """
272    if name.endswith('_pd'):
273        par = model_info.parameters[name[:-3]]
274        if par.type == 'orientation':
275            # Let oriention variation peak around 13 degrees; 95% < 42 degrees
276            return 180*np.random.beta(2.5, 20)
277        else:
278            # Let polydispersity peak around 15%; 95% < 0.4; max=100%
279            return np.random.beta(1.5, 7)
280
281    if name.endswith('_pd_n'):
282        # let pd be selected globally rather than per parameter
283        return 0
284
285    if name.endswith('_pd_type'):
286        # Don't mess with distribution type for now
287        return 'gaussian'
288
289    if name.endswith('_pd_nsigma'):
290        # type-dependent value; for gaussian use 3.
291        return 3.
292
293    if name == 'background':
294        return np.random.uniform(0, 1)
295
296    if name == 'scale':
297        return 10**np.random.uniform(-5,0)
298
299    par = model_info.parameters[name]
300    if len(par.limits) > 2:  # choice list
301        return np.random.randint(len(par.limits))
302
303    if np.isfinite(par.limits).all():
304        return np.random.uniform(*par.limits)
305
306    if par.type == 'sld':
307        # Range of neutron SLDs
308        return np.random.uniform(-0.5, 12)
309
310    if par.type == 'volume':
311        if ('length' in par.name or
312                'radius' in par.name or
313                'thick' in par.name):
314            return 10**np.random.uniform(2,4)
315
316    low, high = parameter_range(par.name, value)
317    limits = (max(par.limits[0], low), min(par.limits[1], high))
318    return np.random.uniform(*limits)
319
320
321def randomize_pars(model_info, pars, seed=None):
322    # type: (ModelInfo, ParameterSet, int) -> ParameterSet
323    """
324    Generate random values for all of the parameters.
325
326    Valid ranges for the random number generator are guessed from the name of
327    the parameter; this will not account for constraints such as cap radius
328    greater than cylinder radius in the capped_cylinder model, so
329    :func:`constrain_pars` needs to be called afterward..
330    """
331    # Note: the sort guarantees order of calls to random number generator
332    random_pars = dict((p, _randomize_one(model_info, p, v))
333                       for p, v in sorted(pars.items()))
334    if model_info.random is not None:
335        random_pars.update(model_info.random())
336
337    return random_pars
338
339def constrain_pars(model_info, pars):
340    # type: (ModelInfo, ParameterSet) -> None
341    """
342    Restrict parameters to valid values.
343
344    This includes model specific code for models such as capped_cylinder
345    which need to support within model constraints (cap radius more than
346    cylinder radius in this case).
347
348    Warning: this updates the *pars* dictionary in place.
349    """
350    name = model_info.id
351    # if it is a product model, then just look at the form factor since
352    # none of the structure factors need any constraints.
353    if '*' in name:
354        name = name.split('*')[0]
355
356    # Suppress magnetism for python models (not yet implemented)
357    if callable(model_info.Iq):
358        pars.update(suppress_magnetism(pars))
359
360    if name == 'barbell':
361        if pars['radius_bell'] < pars['radius']:
362            pars['radius'], pars['radius_bell'] = pars['radius_bell'], pars['radius']
363
364    elif name == 'capped_cylinder':
365        if pars['radius_cap'] < pars['radius']:
366            pars['radius'], pars['radius_cap'] = pars['radius_cap'], pars['radius']
367
368    elif name == 'guinier':
369        # Limit guinier to an Rg such that Iq > 1e-30 (single precision cutoff)
370        # I(q) = A e^-(Rg^2 q^2/3) > e^-(30 ln 10)
371        # => ln A - (Rg^2 q^2/3) > -30 ln 10
372        # => Rg^2 q^2/3 < 30 ln 10 + ln A
373        # => Rg < sqrt(90 ln 10 + 3 ln A)/q
374        #q_max = 0.2  # mid q maximum
375        q_max = 1.0  # high q maximum
376        rg_max = np.sqrt(90*np.log(10) + 3*np.log(pars['scale']))/q_max
377        pars['rg'] = min(pars['rg'], rg_max)
378
379    elif name == 'pearl_necklace':
380        if pars['radius'] < pars['thick_string']:
381            pars['radius'], pars['thick_string'] = pars['thick_string'], pars['radius']
382        pass
383
384    elif name == 'rpa':
385        # Make sure phi sums to 1.0
386        if pars['case_num'] < 2:
387            pars['Phi1'] = 0.
388            pars['Phi2'] = 0.
389        elif pars['case_num'] < 5:
390            pars['Phi1'] = 0.
391        total = sum(pars['Phi'+c] for c in '1234')
392        for c in '1234':
393            pars['Phi'+c] /= total
394
395def parlist(model_info, pars, is2d):
396    # type: (ModelInfo, ParameterSet, bool) -> str
397    """
398    Format the parameter list for printing.
399    """
400    lines = []
401    parameters = model_info.parameters
402    magnetic = False
403    for p in parameters.user_parameters(pars, is2d):
404        if any(p.id.startswith(x) for x in ('M0:', 'mtheta:', 'mphi:')):
405            continue
406        if p.id.startswith('up:') and not magnetic:
407            continue
408        fields = dict(
409            value=pars.get(p.id, p.default),
410            pd=pars.get(p.id+"_pd", 0.),
411            n=int(pars.get(p.id+"_pd_n", 0)),
412            nsigma=pars.get(p.id+"_pd_nsgima", 3.),
413            pdtype=pars.get(p.id+"_pd_type", 'gaussian'),
414            relative_pd=p.relative_pd,
415            M0=pars.get('M0:'+p.id, 0.),
416            mphi=pars.get('mphi:'+p.id, 0.),
417            mtheta=pars.get('mtheta:'+p.id, 0.),
418        )
419        lines.append(_format_par(p.name, **fields))
420        magnetic = magnetic or fields['M0'] != 0.
421    return "\n".join(lines)
422
423    #return "\n".join("%s: %s"%(p, v) for p, v in sorted(pars.items()))
424
425def _format_par(name, value=0., pd=0., n=0, nsigma=3., pdtype='gaussian',
426                relative_pd=False, M0=0., mphi=0., mtheta=0.):
427    # type: (str, float, float, int, float, str) -> str
428    line = "%s: %g"%(name, value)
429    if pd != 0.  and n != 0:
430        if relative_pd:
431            pd *= value
432        line += " +/- %g  (%d points in [-%g,%g] sigma %s)"\
433                % (pd, n, nsigma, nsigma, pdtype)
434    if M0 != 0.:
435        line += "  M0:%.3f  mphi:%.1f  mtheta:%.1f" % (M0, mphi, mtheta)
436    return line
437
438def suppress_pd(pars):
439    # type: (ParameterSet) -> ParameterSet
440    """
441    Suppress theta_pd for now until the normalization is resolved.
442
443    May also suppress complete polydispersity of the model to test
444    models more quickly.
445    """
446    pars = pars.copy()
447    for p in pars:
448        if p.endswith("_pd_n"): pars[p] = 0
449    return pars
450
451def suppress_magnetism(pars):
452    # type: (ParameterSet) -> ParameterSet
453    """
454    Suppress theta_pd for now until the normalization is resolved.
455
456    May also suppress complete polydispersity of the model to test
457    models more quickly.
458    """
459    pars = pars.copy()
460    for p in pars:
461        if p.startswith("M0:"): pars[p] = 0
462    return pars
463
464def eval_sasview(model_info, data):
465    # type: (Modelinfo, Data) -> Calculator
466    """
467    Return a model calculator using the pre-4.0 SasView models.
468    """
469    # importing sas here so that the error message will be that sas failed to
470    # import rather than the more obscure smear_selection not imported error
471    import sas
472    import sas.models
473    from sas.models.qsmearing import smear_selection
474    from sas.models.MultiplicationModel import MultiplicationModel
475    from sas.models.dispersion_models import models as dispersers
476
477    def get_model_class(name):
478        # type: (str) -> "sas.models.BaseComponent"
479        #print("new",sorted(_pars.items()))
480        __import__('sas.models.' + name)
481        ModelClass = getattr(getattr(sas.models, name, None), name, None)
482        if ModelClass is None:
483            raise ValueError("could not find model %r in sas.models"%name)
484        return ModelClass
485
486    # WARNING: ugly hack when handling model!
487    # Sasview models with multiplicity need to be created with the target
488    # multiplicity, so we cannot create the target model ahead of time for
489    # for multiplicity models.  Instead we store the model in a list and
490    # update the first element of that list with the new multiplicity model
491    # every time we evaluate.
492
493    # grab the sasview model, or create it if it is a product model
494    if model_info.composition:
495        composition_type, parts = model_info.composition
496        if composition_type == 'product':
497            P, S = [get_model_class(revert_name(p))() for p in parts]
498            model = [MultiplicationModel(P, S)]
499        else:
500            raise ValueError("sasview mixture models not supported by compare")
501    else:
502        old_name = revert_name(model_info)
503        if old_name is None:
504            raise ValueError("model %r does not exist in old sasview"
505                            % model_info.id)
506        ModelClass = get_model_class(old_name)
507        model = [ModelClass()]
508    model[0].disperser_handles = {}
509
510    # build a smearer with which to call the model, if necessary
511    smearer = smear_selection(data, model=model)
512    if hasattr(data, 'qx_data'):
513        q = np.sqrt(data.qx_data**2 + data.qy_data**2)
514        index = ((~data.mask) & (~np.isnan(data.data))
515                 & (q >= data.qmin) & (q <= data.qmax))
516        if smearer is not None:
517            smearer.model = model  # because smear_selection has a bug
518            smearer.accuracy = data.accuracy
519            smearer.set_index(index)
520            def _call_smearer():
521                smearer.model = model[0]
522                return smearer.get_value()
523            theory = _call_smearer
524        else:
525            theory = lambda: model[0].evalDistribution([data.qx_data[index],
526                                                        data.qy_data[index]])
527    elif smearer is not None:
528        theory = lambda: smearer(model[0].evalDistribution(data.x))
529    else:
530        theory = lambda: model[0].evalDistribution(data.x)
531
532    def calculator(**pars):
533        # type: (float, ...) -> np.ndarray
534        """
535        Sasview calculator for model.
536        """
537        oldpars = revert_pars(model_info, pars)
538        # For multiplicity models, create a model with the correct multiplicity
539        control = oldpars.pop("CONTROL", None)
540        if control is not None:
541            # sphericalSLD has one fewer multiplicity.  This update should
542            # happen in revert_pars, but it hasn't been called yet.
543            model[0] = ModelClass(control)
544        # paying for parameter conversion each time to keep life simple, if not fast
545        for k, v in oldpars.items():
546            if k.endswith('.type'):
547                par = k[:-5]
548                if v == 'gaussian': continue
549                cls = dispersers[v if v != 'rectangle' else 'rectangula']
550                handle = cls()
551                model[0].disperser_handles[par] = handle
552                try:
553                    model[0].set_dispersion(par, handle)
554                except Exception:
555                    exception.annotate_exception("while setting %s to %r"
556                                                 %(par, v))
557                    raise
558
559
560        #print("sasview pars",oldpars)
561        for k, v in oldpars.items():
562            name_attr = k.split('.')  # polydispersity components
563            if len(name_attr) == 2:
564                par, disp_par = name_attr
565                model[0].dispersion[par][disp_par] = v
566            else:
567                model[0].setParam(k, v)
568        return theory()
569
570    calculator.engine = "sasview"
571    return calculator
572
573DTYPE_MAP = {
574    'half': '16',
575    'fast': 'fast',
576    'single': '32',
577    'double': '64',
578    'quad': '128',
579    'f16': '16',
580    'f32': '32',
581    'f64': '64',
582    'float16': '16',
583    'float32': '32',
584    'float64': '64',
585    'float128': '128',
586    'longdouble': '128',
587}
588def eval_opencl(model_info, data, dtype='single', cutoff=0.):
589    # type: (ModelInfo, Data, str, float) -> Calculator
590    """
591    Return a model calculator using the OpenCL calculation engine.
592    """
593    if not core.HAVE_OPENCL:
594        raise RuntimeError("OpenCL not available")
595    model = core.build_model(model_info, dtype=dtype, platform="ocl")
596    calculator = DirectModel(data, model, cutoff=cutoff)
597    calculator.engine = "OCL%s"%DTYPE_MAP[dtype]
598    return calculator
599
600def eval_ctypes(model_info, data, dtype='double', cutoff=0.):
601    # type: (ModelInfo, Data, str, float) -> Calculator
602    """
603    Return a model calculator using the DLL calculation engine.
604    """
605    model = core.build_model(model_info, dtype=dtype, platform="dll")
606    calculator = DirectModel(data, model, cutoff=cutoff)
607    calculator.engine = "OMP%s"%DTYPE_MAP[dtype]
608    return calculator
609
610def time_calculation(calculator, pars, evals=1):
611    # type: (Calculator, ParameterSet, int) -> Tuple[np.ndarray, float]
612    """
613    Compute the average calculation time over N evaluations.
614
615    An additional call is generated without polydispersity in order to
616    initialize the calculation engine, and make the average more stable.
617    """
618    # initialize the code so time is more accurate
619    if evals > 1:
620        calculator(**suppress_pd(pars))
621    toc = tic()
622    # make sure there is at least one eval
623    value = calculator(**pars)
624    for _ in range(evals-1):
625        value = calculator(**pars)
626    average_time = toc()*1000. / evals
627    #print("I(q)",value)
628    return value, average_time
629
630def make_data(opts):
631    # type: (Dict[str, Any]) -> Tuple[Data, np.ndarray]
632    """
633    Generate an empty dataset, used with the model to set Q points
634    and resolution.
635
636    *opts* contains the options, with 'qmax', 'nq', 'res',
637    'accuracy', 'is2d' and 'view' parsed from the command line.
638    """
639    qmax, nq, res = opts['qmax'], opts['nq'], opts['res']
640    if opts['is2d']:
641        q = np.linspace(-qmax, qmax, nq)  # type: np.ndarray
642        data = empty_data2D(q, resolution=res)
643        data.accuracy = opts['accuracy']
644        set_beam_stop(data, 0.0004)
645        index = ~data.mask
646    else:
647        if opts['view'] == 'log' and not opts['zero']:
648            qmax = math.log10(qmax)
649            q = np.logspace(qmax-3, qmax, nq)
650        else:
651            q = np.linspace(0.001*qmax, qmax, nq)
652        if opts['zero']:
653            q = np.hstack((0, q))
654        data = empty_data1D(q, resolution=res)
655        index = slice(None, None)
656    return data, index
657
658def make_engine(model_info, data, dtype, cutoff):
659    # type: (ModelInfo, Data, str, float) -> Calculator
660    """
661    Generate the appropriate calculation engine for the given datatype.
662
663    Datatypes with '!' appended are evaluated using external C DLLs rather
664    than OpenCL.
665    """
666    if dtype == 'sasview':
667        return eval_sasview(model_info, data)
668    elif dtype.endswith('!'):
669        return eval_ctypes(model_info, data, dtype=dtype[:-1], cutoff=cutoff)
670    else:
671        return eval_opencl(model_info, data, dtype=dtype, cutoff=cutoff)
672
673def _show_invalid(data, theory):
674    # type: (Data, np.ma.ndarray) -> None
675    """
676    Display a list of the non-finite values in theory.
677    """
678    if not theory.mask.any():
679        return
680
681    if hasattr(data, 'x'):
682        bad = zip(data.x[theory.mask], theory[theory.mask])
683        print("   *** ", ", ".join("I(%g)=%g"%(x, y) for x, y in bad))
684
685
686def compare(opts, limits=None):
687    # type: (Dict[str, Any], Optional[Tuple[float, float]]) -> Tuple[float, float]
688    """
689    Preform a comparison using options from the command line.
690
691    *limits* are the limits on the values to use, either to set the y-axis
692    for 1D or to set the colormap scale for 2D.  If None, then they are
693    inferred from the data and returned. When exploring using Bumps,
694    the limits are set when the model is initially called, and maintained
695    as the values are adjusted, making it easier to see the effects of the
696    parameters.
697    """
698    limits = np.Inf, -np.Inf
699    for k in range(opts['sets']):
700        opts['pars'] = parse_pars(opts)
701        result = run_models(opts, verbose=True)
702        if opts['plot']:
703            limits = plot_models(opts, result, limits=limits, setnum=k)
704    if opts['plot']:
705        import matplotlib.pyplot as plt
706        plt.show()
707
708def run_models(opts, verbose=False):
709    # type: (Dict[str, Any]) -> Dict[str, Any]
710
711    n_base, n_comp = opts['count']
712    pars, pars2 = opts['pars']
713    data = opts['data']
714
715    # silence the linter
716    base = opts['engines'][0] if n_base else None
717    comp = opts['engines'][1] if n_comp else None
718
719    base_time = comp_time = None
720    base_value = comp_value = resid = relerr = None
721
722    # Base calculation
723    if n_base > 0:
724        try:
725            base_raw, base_time = time_calculation(base, pars, n_base)
726            base_value = np.ma.masked_invalid(base_raw)
727            if verbose:
728                print("%s t=%.2f ms, intensity=%.0f"
729                      % (base.engine, base_time, base_value.sum()))
730            _show_invalid(data, base_value)
731        except ImportError:
732            traceback.print_exc()
733            n_base = 0
734
735    # Comparison calculation
736    if n_comp > 0:
737        try:
738            comp_raw, comp_time = time_calculation(comp, pars2, n_comp)
739            comp_value = np.ma.masked_invalid(comp_raw)
740            if verbose:
741                print("%s t=%.2f ms, intensity=%.0f"
742                      % (comp.engine, comp_time, comp_value.sum()))
743            _show_invalid(data, comp_value)
744        except ImportError:
745            traceback.print_exc()
746            n_comp = 0
747
748    # Compare, but only if computing both forms
749    if n_base > 0 and n_comp > 0:
750        resid = (base_value - comp_value)
751        relerr = resid/np.where(comp_value != 0., abs(comp_value), 1.0)
752        if verbose:
753            _print_stats("|%s-%s|"
754                         % (base.engine, comp.engine) + (" "*(3+len(comp.engine))),
755                         resid)
756            _print_stats("|(%s-%s)/%s|"
757                         % (base.engine, comp.engine, comp.engine),
758                         relerr)
759
760    return dict(base_value=base_value, comp_value=comp_value,
761                base_time=base_time, comp_time=comp_time,
762                resid=resid, relerr=relerr)
763
764
765def _print_stats(label, err):
766    # type: (str, np.ma.ndarray) -> None
767    # work with trimmed data, not the full set
768    sorted_err = np.sort(abs(err.compressed()))
769    if len(sorted_err) == 0.:
770        print(label + "  no valid values")
771        return
772
773    p50 = int((len(sorted_err)-1)*0.50)
774    p98 = int((len(sorted_err)-1)*0.98)
775    data = [
776        "max:%.3e"%sorted_err[-1],
777        "median:%.3e"%sorted_err[p50],
778        "98%%:%.3e"%sorted_err[p98],
779        "rms:%.3e"%np.sqrt(np.mean(sorted_err**2)),
780        "zero-offset:%+.3e"%np.mean(sorted_err),
781        ]
782    print(label+"  "+"  ".join(data))
783
784
785def plot_models(opts, result, limits=(np.Inf, -np.Inf), setnum=0):
786    # type: (Dict[str, Any], Dict[str, Any], Optional[Tuple[float, float]]) -> Tuple[float, float]
787    base_value, comp_value= result['base_value'], result['comp_value']
788    base_time, comp_time = result['base_time'], result['comp_time']
789    resid, relerr = result['resid'], result['relerr']
790
791    have_base, have_comp = (base_value is not None), (comp_value is not None)
792    base = opts['engines'][0] if have_base else None
793    comp = opts['engines'][1] if have_comp else None
794    data = opts['data']
795    use_data = (opts['datafile'] is not None) and (have_base ^ have_comp)
796
797    # Plot if requested
798    view = opts['view']
799    import matplotlib.pyplot as plt
800    vmin, vmax = limits
801    if have_base:
802        vmin = min(vmin, base_value.min())
803        vmax = max(vmax, base_value.max())
804    if have_comp:
805        vmin = min(vmin, comp_value.min())
806        vmax = max(vmax, comp_value.max())
807    limits = vmin, vmax
808
809    if have_base:
810        if have_comp: plt.subplot(131)
811        plot_theory(data, base_value, view=view, use_data=use_data, limits=limits)
812        plt.title("%s t=%.2f ms"%(base.engine, base_time))
813        #cbar_title = "log I"
814    if have_comp:
815        if have_base: plt.subplot(132)
816        if not opts['is2d'] and have_base:
817            plot_theory(data, base_value, view=view, use_data=use_data, limits=limits)
818        plot_theory(data, comp_value, view=view, use_data=use_data, limits=limits)
819        plt.title("%s t=%.2f ms"%(comp.engine, comp_time))
820        #cbar_title = "log I"
821    if have_base and have_comp:
822        plt.subplot(133)
823        if not opts['rel_err']:
824            err, errstr, errview = resid, "abs err", "linear"
825        else:
826            err, errstr, errview = abs(relerr), "rel err", "log"
827        if 0:  # 95% cutoff
828            sorted = np.sort(err.flatten())
829            cutoff = sorted[int(sorted.size*0.95)]
830            err[err>cutoff] = cutoff
831        #err,errstr = base/comp,"ratio"
832        plot_theory(data, None, resid=err, view=errview, use_data=use_data)
833        if view == 'linear':
834            plt.xscale('linear')
835        plt.title("max %s = %.3g"%(errstr, abs(err).max()))
836        #cbar_title = errstr if errview=="linear" else "log "+errstr
837    #if is2D:
838    #    h = plt.colorbar()
839    #    h.ax.set_title(cbar_title)
840    fig = plt.gcf()
841    extra_title = ' '+opts['title'] if opts['title'] else ''
842    fig.suptitle(":".join(opts['name']) + extra_title)
843
844    if have_base and have_comp and opts['show_hist']:
845        plt.figure()
846        v = relerr
847        v[v == 0] = 0.5*np.min(np.abs(v[v != 0]))
848        plt.hist(np.log10(np.abs(v)), normed=1, bins=50)
849        plt.xlabel('log10(err), err = |(%s - %s) / %s|'
850                   % (base.engine, comp.engine, comp.engine))
851        plt.ylabel('P(err)')
852        plt.title('Distribution of relative error between calculation engines')
853
854    return limits
855
856
857# ===========================================================================
858#
859NAME_OPTIONS = set([
860    'plot', 'noplot',
861    'half', 'fast', 'single', 'double',
862    'single!', 'double!', 'quad!', 'sasview',
863    'lowq', 'midq', 'highq', 'exq', 'zero',
864    '2d', '1d',
865    'preset', 'random',
866    'poly', 'mono',
867    'magnetic', 'nonmagnetic',
868    'nopars', 'pars',
869    'rel', 'abs',
870    'linear', 'log', 'q4',
871    'hist', 'nohist',
872    'edit', 'html', 'help',
873    'demo', 'default',
874    ])
875VALUE_OPTIONS = [
876    # Note: random is both a name option and a value option
877    'cutoff', 'random', 'nq', 'res', 'accuracy', 'title', 'data', 'sets'
878    ]
879
880def columnize(items, indent="", width=79):
881    # type: (List[str], str, int) -> str
882    """
883    Format a list of strings into columns.
884
885    Returns a string with carriage returns ready for printing.
886    """
887    column_width = max(len(w) for w in items) + 1
888    num_columns = (width - len(indent)) // column_width
889    num_rows = len(items) // num_columns
890    items = items + [""] * (num_rows * num_columns - len(items))
891    columns = [items[k*num_rows:(k+1)*num_rows] for k in range(num_columns)]
892    lines = [" ".join("%-*s"%(column_width, entry) for entry in row)
893             for row in zip(*columns)]
894    output = indent + ("\n"+indent).join(lines)
895    return output
896
897
898def get_pars(model_info, use_demo=False):
899    # type: (ModelInfo, bool) -> ParameterSet
900    """
901    Extract demo parameters from the model definition.
902    """
903    # Get the default values for the parameters
904    pars = {}
905    for p in model_info.parameters.call_parameters:
906        parts = [('', p.default)]
907        if p.polydisperse:
908            parts.append(('_pd', 0.0))
909            parts.append(('_pd_n', 0))
910            parts.append(('_pd_nsigma', 3.0))
911            parts.append(('_pd_type', "gaussian"))
912        for ext, val in parts:
913            if p.length > 1:
914                dict(("%s%d%s" % (p.id, k, ext), val)
915                     for k in range(1, p.length+1))
916            else:
917                pars[p.id + ext] = val
918
919    # Plug in values given in demo
920    if use_demo:
921        pars.update(model_info.demo)
922    return pars
923
924INTEGER_RE = re.compile("^[+-]?[1-9][0-9]*$")
925def isnumber(str):
926    match = FLOAT_RE.match(str)
927    isfloat = (match and not str[match.end():])
928    return isfloat or INTEGER_RE.match(str)
929
930# For distinguishing pairs of models for comparison
931# key-value pair separator =
932# shell characters  | & ; <> $ % ' " \ # `
933# model and parameter names _
934# parameter expressions - + * / . ( )
935# path characters including tilde expansion and windows drive ~ / :
936# not sure about brackets [] {}
937# maybe one of the following @ ? ^ ! ,
938MODEL_SPLIT = ','
939def parse_opts(argv):
940    # type: (List[str]) -> Dict[str, Any]
941    """
942    Parse command line options.
943    """
944    MODELS = core.list_models()
945    flags = [arg for arg in argv
946             if arg.startswith('-')]
947    values = [arg for arg in argv
948              if not arg.startswith('-') and '=' in arg]
949    positional_args = [arg for arg in argv
950                       if not arg.startswith('-') and '=' not in arg]
951    models = "\n    ".join("%-15s"%v for v in MODELS)
952    if len(positional_args) == 0:
953        print(USAGE)
954        print("\nAvailable models:")
955        print(columnize(MODELS, indent="  "))
956        return None
957    if len(positional_args) > 3:
958        print("expected parameters: model N1 N2")
959
960    invalid = [o[1:] for o in flags
961               if o[1:] not in NAME_OPTIONS
962               and not any(o.startswith('-%s='%t) for t in VALUE_OPTIONS)]
963    if invalid:
964        print("Invalid options: %s"%(", ".join(invalid)))
965        return None
966
967    name = positional_args[0]
968    n1 = int(positional_args[1]) if len(positional_args) > 1 else 1
969    n2 = int(positional_args[2]) if len(positional_args) > 2 else 1
970
971    # pylint: disable=bad-whitespace
972    # Interpret the flags
973    opts = {
974        'plot'      : True,
975        'view'      : 'log',
976        'is2d'      : False,
977        'qmax'      : 0.05,
978        'nq'        : 128,
979        'res'       : 0.0,
980        'accuracy'  : 'Low',
981        'cutoff'    : 0.0,
982        'seed'      : -1,  # default to preset
983        'mono'      : True,
984        # Default to magnetic a magnetic moment is set on the command line
985        'magnetic'  : False,
986        'show_pars' : False,
987        'show_hist' : False,
988        'rel_err'   : True,
989        'explore'   : False,
990        'use_demo'  : True,
991        'zero'      : False,
992        'html'      : False,
993        'title'     : None,
994        'datafile'  : None,
995        'sets'      : 1,
996    }
997    engines = []
998    for arg in flags:
999        if arg == '-noplot':    opts['plot'] = False
1000        elif arg == '-plot':    opts['plot'] = True
1001        elif arg == '-linear':  opts['view'] = 'linear'
1002        elif arg == '-log':     opts['view'] = 'log'
1003        elif arg == '-q4':      opts['view'] = 'q4'
1004        elif arg == '-1d':      opts['is2d'] = False
1005        elif arg == '-2d':      opts['is2d'] = True
1006        elif arg == '-exq':     opts['qmax'] = 10.0
1007        elif arg == '-highq':   opts['qmax'] = 1.0
1008        elif arg == '-midq':    opts['qmax'] = 0.2
1009        elif arg == '-lowq':    opts['qmax'] = 0.05
1010        elif arg == '-zero':    opts['zero'] = True
1011        elif arg.startswith('-nq='):       opts['nq'] = int(arg[4:])
1012        elif arg.startswith('-res='):      opts['res'] = float(arg[5:])
1013        elif arg.startswith('-sets='):     opts['sets'] = int(arg[6:])
1014        elif arg.startswith('-accuracy='): opts['accuracy'] = arg[10:]
1015        elif arg.startswith('-cutoff='):   opts['cutoff'] = float(arg[8:])
1016        elif arg.startswith('-random='):   opts['seed'] = int(arg[8:])
1017        elif arg.startswith('-title='):    opts['title'] = arg[7:]
1018        elif arg.startswith('-data='):     opts['datafile'] = arg[6:]
1019        elif arg == '-random':  opts['seed'] = np.random.randint(1000000)
1020        elif arg == '-preset':  opts['seed'] = -1
1021        elif arg == '-mono':    opts['mono'] = True
1022        elif arg == '-poly':    opts['mono'] = False
1023        elif arg == '-magnetic':       opts['magnetic'] = True
1024        elif arg == '-nonmagnetic':    opts['magnetic'] = False
1025        elif arg == '-pars':    opts['show_pars'] = True
1026        elif arg == '-nopars':  opts['show_pars'] = False
1027        elif arg == '-hist':    opts['show_hist'] = True
1028        elif arg == '-nohist':  opts['show_hist'] = False
1029        elif arg == '-rel':     opts['rel_err'] = True
1030        elif arg == '-abs':     opts['rel_err'] = False
1031        elif arg == '-half':    engines.append(arg[1:])
1032        elif arg == '-fast':    engines.append(arg[1:])
1033        elif arg == '-single':  engines.append(arg[1:])
1034        elif arg == '-double':  engines.append(arg[1:])
1035        elif arg == '-single!': engines.append(arg[1:])
1036        elif arg == '-double!': engines.append(arg[1:])
1037        elif arg == '-quad!':   engines.append(arg[1:])
1038        elif arg == '-sasview': engines.append(arg[1:])
1039        elif arg == '-edit':    opts['explore'] = True
1040        elif arg == '-demo':    opts['use_demo'] = True
1041        elif arg == '-default':    opts['use_demo'] = False
1042        elif arg == '-html':    opts['html'] = True
1043        elif arg == '-help':    opts['html'] = True
1044    # pylint: enable=bad-whitespace
1045
1046    # Force random if more than one set
1047    if opts['sets'] > 1 and opts['seed'] < 0:
1048        opts['seed'] = np.random.randint(1000000)
1049
1050    if MODEL_SPLIT in name:
1051        name, name2 = name.split(MODEL_SPLIT, 2)
1052    else:
1053        name2 = name
1054    try:
1055        model_info = core.load_model_info(name)
1056        model_info2 = core.load_model_info(name2) if name2 != name else model_info
1057    except ImportError as exc:
1058        print(str(exc))
1059        print("Could not find model; use one of:\n    " + models)
1060        return None
1061
1062    # TODO: check if presets are different when deciding if models are same
1063    same_model = name == name2
1064    if len(engines) == 0:
1065        if same_model:
1066            engines.extend(['single', 'double'])
1067        else:
1068            engines.extend(['single', 'single'])
1069    elif len(engines) == 1:
1070        if not same_model:
1071            engines.append(engines[0])
1072        elif engines[0] == 'double':
1073            engines.append('single')
1074        else:
1075            engines.append('double')
1076    elif len(engines) > 2:
1077        del engines[2:]
1078
1079    # Create the computational engines
1080    if opts['datafile'] is not None:
1081        data = load_data(os.path.expanduser(opts['datafile']))
1082    else:
1083        data, _ = make_data(opts)
1084    if n1:
1085        base = make_engine(model_info, data, engines[0], opts['cutoff'])
1086    else:
1087        base = None
1088    if n2:
1089        comp = make_engine(model_info2, data, engines[1], opts['cutoff'])
1090    else:
1091        comp = None
1092
1093    # pylint: disable=bad-whitespace
1094    # Remember it all
1095    opts.update({
1096        'data'      : data,
1097        'name'      : [name, name2],
1098        'def'       : [model_info, model_info2],
1099        'count'     : [n1, n2],
1100        'engines'   : [base, comp],
1101        'values'    : values,
1102    })
1103    # pylint: enable=bad-whitespace
1104
1105    return opts
1106
1107def parse_pars(opts):
1108    model_info, model_info2 = opts['def']
1109
1110    # Get demo parameters from model definition, or use default parameters
1111    # if model does not define demo parameters
1112    pars = get_pars(model_info, opts['use_demo'])
1113    pars2 = get_pars(model_info2, opts['use_demo'])
1114    pars2.update((k, v) for k, v in pars.items() if k in pars2)
1115    # randomize parameters
1116    #pars.update(set_pars)  # set value before random to control range
1117    if opts['seed'] > -1:
1118        pars = randomize_pars(model_info, pars)
1119        if model_info != model_info2:
1120            pars2 = randomize_pars(model_info2, pars2)
1121            # Share values for parameters with the same name
1122            for k, v in pars.items():
1123                if k in pars2:
1124                    pars2[k] = v
1125        else:
1126            pars2 = pars.copy()
1127        constrain_pars(model_info, pars)
1128        constrain_pars(model_info2, pars2)
1129    if opts['mono']:
1130        pars = suppress_pd(pars)
1131        pars2 = suppress_pd(pars2)
1132    if not opts['magnetic']:
1133        pars = suppress_magnetism(pars)
1134        pars2 = suppress_magnetism(pars2)
1135
1136    # Fill in parameters given on the command line
1137    presets = {}
1138    presets2 = {}
1139    for arg in opts['values']:
1140        k, v = arg.split('=', 1)
1141        if k not in pars and k not in pars2:
1142            # extract base name without polydispersity info
1143            s = set(p.split('_pd')[0] for p in pars)
1144            print("%r invalid; parameters are: %s"%(k, ", ".join(sorted(s))))
1145            return None
1146        v1, v2 = v.split(MODEL_SPLIT, 2) if MODEL_SPLIT in v else (v,v)
1147        if v1 and k in pars:
1148            presets[k] = float(v1) if isnumber(v1) else v1
1149        if v2 and k in pars2:
1150            presets2[k] = float(v2) if isnumber(v2) else v2
1151
1152    # If pd given on the command line, default pd_n to 35
1153    for k, v in list(presets.items()):
1154        if k.endswith('_pd'):
1155            presets.setdefault(k+'_n', 35.)
1156    for k, v in list(presets2.items()):
1157        if k.endswith('_pd'):
1158            presets2.setdefault(k+'_n', 35.)
1159
1160    # Evaluate preset parameter expressions
1161    context = MATH.copy()
1162    context['np'] = np
1163    context.update(pars)
1164    context.update((k, v) for k, v in presets.items() if isinstance(v, float))
1165    for k, v in presets.items():
1166        if not isinstance(v, float) and not k.endswith('_type'):
1167            presets[k] = eval(v, context)
1168    context.update(presets)
1169    context.update((k, v) for k, v in presets2.items() if isinstance(v, float))
1170    for k, v in presets2.items():
1171        if not isinstance(v, float) and not k.endswith('_type'):
1172            presets2[k] = eval(v, context)
1173
1174    # update parameters with presets
1175    pars.update(presets)  # set value after random to control value
1176    pars2.update(presets2)  # set value after random to control value
1177    #import pprint; pprint.pprint(model_info)
1178
1179    if opts['show_pars']:
1180        if model_info.name != model_info2.name or pars != pars2:
1181            print("==== %s ====="%model_info.name)
1182            print(str(parlist(model_info, pars, opts['is2d'])))
1183            print("==== %s ====="%model_info2.name)
1184            print(str(parlist(model_info2, pars2, opts['is2d'])))
1185        else:
1186            print(str(parlist(model_info, pars, opts['is2d'])))
1187
1188    return pars, pars2
1189
1190def show_docs(opts):
1191    # type: (Dict[str, Any]) -> None
1192    """
1193    show html docs for the model
1194    """
1195    import os
1196    from .generate import make_html
1197    from . import rst2html
1198
1199    info = opts['def'][0]
1200    html = make_html(info)
1201    path = os.path.dirname(info.filename)
1202    url = "file://"+path.replace("\\","/")[2:]+"/"
1203    rst2html.view_html_qtapp(html, url)
1204
1205def explore(opts):
1206    # type: (Dict[str, Any]) -> None
1207    """
1208    explore the model using the bumps gui.
1209    """
1210    import wx  # type: ignore
1211    from bumps.names import FitProblem  # type: ignore
1212    from bumps.gui.app_frame import AppFrame  # type: ignore
1213    from bumps.gui import signal
1214
1215    is_mac = "cocoa" in wx.version()
1216    # Create an app if not running embedded
1217    app = wx.App() if wx.GetApp() is None else None
1218    model = Explore(opts)
1219    problem = FitProblem(model)
1220    frame = AppFrame(parent=None, title="explore", size=(1000, 700))
1221    if not is_mac:
1222        frame.Show()
1223    frame.panel.set_model(model=problem)
1224    frame.panel.Layout()
1225    frame.panel.aui.Split(0, wx.TOP)
1226    def reset_parameters(event):
1227        model.revert_values()
1228        signal.update_parameters(problem)
1229    frame.Bind(wx.EVT_TOOL, reset_parameters, frame.ToolBar.GetToolByPos(1))
1230    if is_mac: frame.Show()
1231    # If running withing an app, start the main loop
1232    if app:
1233        app.MainLoop()
1234
1235class Explore(object):
1236    """
1237    Bumps wrapper for a SAS model comparison.
1238
1239    The resulting object can be used as a Bumps fit problem so that
1240    parameters can be adjusted in the GUI, with plots updated on the fly.
1241    """
1242    def __init__(self, opts):
1243        # type: (Dict[str, Any]) -> None
1244        from bumps.cli import config_matplotlib  # type: ignore
1245        from . import bumps_model
1246        config_matplotlib()
1247        self.opts = opts
1248        opts['pars'] = list(opts['pars'])
1249        p1, p2 = opts['pars']
1250        m1, m2 = opts['def']
1251        self.fix_p2 = m1 != m2 or p1 != p2
1252        model_info = m1
1253        pars, pd_types = bumps_model.create_parameters(model_info, **p1)
1254        # Initialize parameter ranges, fixing the 2D parameters for 1D data.
1255        if not opts['is2d']:
1256            for p in model_info.parameters.user_parameters({}, is2d=False):
1257                for ext in ['', '_pd', '_pd_n', '_pd_nsigma']:
1258                    k = p.name+ext
1259                    v = pars.get(k, None)
1260                    if v is not None:
1261                        v.range(*parameter_range(k, v.value))
1262        else:
1263            for k, v in pars.items():
1264                v.range(*parameter_range(k, v.value))
1265
1266        self.pars = pars
1267        self.starting_values = dict((k, v.value) for k, v in pars.items())
1268        self.pd_types = pd_types
1269        self.limits = np.Inf, -np.Inf
1270
1271    def revert_values(self):
1272        for k, v in self.starting_values.items():
1273            self.pars[k].value = v
1274
1275    def model_update(self):
1276        pass
1277
1278    def numpoints(self):
1279        # type: () -> int
1280        """
1281        Return the number of points.
1282        """
1283        return len(self.pars) + 1  # so dof is 1
1284
1285    def parameters(self):
1286        # type: () -> Any   # Dict/List hierarchy of parameters
1287        """
1288        Return a dictionary of parameters.
1289        """
1290        return self.pars
1291
1292    def nllf(self):
1293        # type: () -> float
1294        """
1295        Return cost.
1296        """
1297        # pylint: disable=no-self-use
1298        return 0.  # No nllf
1299
1300    def plot(self, view='log'):
1301        # type: (str) -> None
1302        """
1303        Plot the data and residuals.
1304        """
1305        pars = dict((k, v.value) for k, v in self.pars.items())
1306        pars.update(self.pd_types)
1307        self.opts['pars'][0] = pars
1308        if not self.fix_p2:
1309            self.opts['pars'][1] = pars
1310        result = run_models(self.opts)
1311        limits = plot_models(self.opts, result, limits=self.limits)
1312        if self.limits is None:
1313            vmin, vmax = limits
1314            self.limits = vmax*1e-7, 1.3*vmax
1315            import pylab; pylab.clf()
1316            plot_models(self.opts, result, limits=self.limits)
1317
1318
1319def main(*argv):
1320    # type: (*str) -> None
1321    """
1322    Main program.
1323    """
1324    opts = parse_opts(argv)
1325    if opts is not None:
1326        if opts['seed'] > -1:
1327            print("Randomize using -random=%i"%opts['seed'])
1328            np.random.seed(opts['seed'])
1329        if opts['html']:
1330            show_docs(opts)
1331        elif opts['explore']:
1332            opts['pars'] = parse_pars(opts)
1333            explore(opts)
1334        else:
1335            compare(opts)
1336
1337if __name__ == "__main__":
1338    main(*sys.argv[1:])
Note: See TracBrowser for help on using the repository browser.