source: sasmodels/sasmodels/compare.py @ 0bdddc2

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

extend sascomp so it can display sets of random models; extend model def to allow random parameter generation

  • Property mode set to 100755
File size: 45.8 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        #q_max = 0.2  # mid q maximum
371        q_max = 1.0  # high q maximum
372        rg_max = np.sqrt(90*np.log(10) + 3*np.log(pars['scale']))/q_max
373        pars['rg'] = min(pars['rg'], rg_max)
374
375    elif name == 'pearl_necklace':
376        if pars['radius'] < pars['thick_string']:
377            pars['radius'], pars['thick_string'] = pars['thick_string'], pars['radius']
378        pass
379
380    elif name == 'rpa':
381        # Make sure phi sums to 1.0
382        if pars['case_num'] < 2:
383            pars['Phi1'] = 0.
384            pars['Phi2'] = 0.
385        elif pars['case_num'] < 5:
386            pars['Phi1'] = 0.
387        total = sum(pars['Phi'+c] for c in '1234')
388        for c in '1234':
389            pars['Phi'+c] /= total
390
391def parlist(model_info, pars, is2d):
392    # type: (ModelInfo, ParameterSet, bool) -> str
393    """
394    Format the parameter list for printing.
395    """
396    lines = []
397    parameters = model_info.parameters
398    magnetic = False
399    for p in parameters.user_parameters(pars, is2d):
400        if any(p.id.startswith(x) for x in ('M0:', 'mtheta:', 'mphi:')):
401            continue
402        if p.id.startswith('up:') and not magnetic:
403            continue
404        fields = dict(
405            value=pars.get(p.id, p.default),
406            pd=pars.get(p.id+"_pd", 0.),
407            n=int(pars.get(p.id+"_pd_n", 0)),
408            nsigma=pars.get(p.id+"_pd_nsgima", 3.),
409            pdtype=pars.get(p.id+"_pd_type", 'gaussian'),
410            relative_pd=p.relative_pd,
411            M0=pars.get('M0:'+p.id, 0.),
412            mphi=pars.get('mphi:'+p.id, 0.),
413            mtheta=pars.get('mtheta:'+p.id, 0.),
414        )
415        lines.append(_format_par(p.name, **fields))
416        magnetic = magnetic or fields['M0'] != 0.
417    return "\n".join(lines)
418
419    #return "\n".join("%s: %s"%(p, v) for p, v in sorted(pars.items()))
420
421def _format_par(name, value=0., pd=0., n=0, nsigma=3., pdtype='gaussian',
422                relative_pd=False, M0=0., mphi=0., mtheta=0.):
423    # type: (str, float, float, int, float, str) -> str
424    line = "%s: %g"%(name, value)
425    if pd != 0.  and n != 0:
426        if relative_pd:
427            pd *= value
428        line += " +/- %g  (%d points in [-%g,%g] sigma %s)"\
429                % (pd, n, nsigma, nsigma, pdtype)
430    if M0 != 0.:
431        line += "  M0:%.3f  mphi:%.1f  mtheta:%.1f" % (M0, mphi, mtheta)
432    return line
433
434def suppress_pd(pars):
435    # type: (ParameterSet) -> ParameterSet
436    """
437    Suppress theta_pd for now until the normalization is resolved.
438
439    May also suppress complete polydispersity of the model to test
440    models more quickly.
441    """
442    pars = pars.copy()
443    for p in pars:
444        if p.endswith("_pd_n"): pars[p] = 0
445    return pars
446
447def suppress_magnetism(pars):
448    # type: (ParameterSet) -> ParameterSet
449    """
450    Suppress theta_pd for now until the normalization is resolved.
451
452    May also suppress complete polydispersity of the model to test
453    models more quickly.
454    """
455    pars = pars.copy()
456    for p in pars:
457        if p.startswith("M0:"): pars[p] = 0
458    return pars
459
460def eval_sasview(model_info, data):
461    # type: (Modelinfo, Data) -> Calculator
462    """
463    Return a model calculator using the pre-4.0 SasView models.
464    """
465    # importing sas here so that the error message will be that sas failed to
466    # import rather than the more obscure smear_selection not imported error
467    import sas
468    import sas.models
469    from sas.models.qsmearing import smear_selection
470    from sas.models.MultiplicationModel import MultiplicationModel
471    from sas.models.dispersion_models import models as dispersers
472
473    def get_model_class(name):
474        # type: (str) -> "sas.models.BaseComponent"
475        #print("new",sorted(_pars.items()))
476        __import__('sas.models.' + name)
477        ModelClass = getattr(getattr(sas.models, name, None), name, None)
478        if ModelClass is None:
479            raise ValueError("could not find model %r in sas.models"%name)
480        return ModelClass
481
482    # WARNING: ugly hack when handling model!
483    # Sasview models with multiplicity need to be created with the target
484    # multiplicity, so we cannot create the target model ahead of time for
485    # for multiplicity models.  Instead we store the model in a list and
486    # update the first element of that list with the new multiplicity model
487    # every time we evaluate.
488
489    # grab the sasview model, or create it if it is a product model
490    if model_info.composition:
491        composition_type, parts = model_info.composition
492        if composition_type == 'product':
493            P, S = [get_model_class(revert_name(p))() for p in parts]
494            model = [MultiplicationModel(P, S)]
495        else:
496            raise ValueError("sasview mixture models not supported by compare")
497    else:
498        old_name = revert_name(model_info)
499        if old_name is None:
500            raise ValueError("model %r does not exist in old sasview"
501                            % model_info.id)
502        ModelClass = get_model_class(old_name)
503        model = [ModelClass()]
504    model[0].disperser_handles = {}
505
506    # build a smearer with which to call the model, if necessary
507    smearer = smear_selection(data, model=model)
508    if hasattr(data, 'qx_data'):
509        q = np.sqrt(data.qx_data**2 + data.qy_data**2)
510        index = ((~data.mask) & (~np.isnan(data.data))
511                 & (q >= data.qmin) & (q <= data.qmax))
512        if smearer is not None:
513            smearer.model = model  # because smear_selection has a bug
514            smearer.accuracy = data.accuracy
515            smearer.set_index(index)
516            def _call_smearer():
517                smearer.model = model[0]
518                return smearer.get_value()
519            theory = _call_smearer
520        else:
521            theory = lambda: model[0].evalDistribution([data.qx_data[index],
522                                                        data.qy_data[index]])
523    elif smearer is not None:
524        theory = lambda: smearer(model[0].evalDistribution(data.x))
525    else:
526        theory = lambda: model[0].evalDistribution(data.x)
527
528    def calculator(**pars):
529        # type: (float, ...) -> np.ndarray
530        """
531        Sasview calculator for model.
532        """
533        oldpars = revert_pars(model_info, pars)
534        # For multiplicity models, create a model with the correct multiplicity
535        control = oldpars.pop("CONTROL", None)
536        if control is not None:
537            # sphericalSLD has one fewer multiplicity.  This update should
538            # happen in revert_pars, but it hasn't been called yet.
539            model[0] = ModelClass(control)
540        # paying for parameter conversion each time to keep life simple, if not fast
541        for k, v in oldpars.items():
542            if k.endswith('.type'):
543                par = k[:-5]
544                if v == 'gaussian': continue
545                cls = dispersers[v if v != 'rectangle' else 'rectangula']
546                handle = cls()
547                model[0].disperser_handles[par] = handle
548                try:
549                    model[0].set_dispersion(par, handle)
550                except Exception:
551                    exception.annotate_exception("while setting %s to %r"
552                                                 %(par, v))
553                    raise
554
555
556        #print("sasview pars",oldpars)
557        for k, v in oldpars.items():
558            name_attr = k.split('.')  # polydispersity components
559            if len(name_attr) == 2:
560                par, disp_par = name_attr
561                model[0].dispersion[par][disp_par] = v
562            else:
563                model[0].setParam(k, v)
564        return theory()
565
566    calculator.engine = "sasview"
567    return calculator
568
569DTYPE_MAP = {
570    'half': '16',
571    'fast': 'fast',
572    'single': '32',
573    'double': '64',
574    'quad': '128',
575    'f16': '16',
576    'f32': '32',
577    'f64': '64',
578    'float16': '16',
579    'float32': '32',
580    'float64': '64',
581    'float128': '128',
582    'longdouble': '128',
583}
584def eval_opencl(model_info, data, dtype='single', cutoff=0.):
585    # type: (ModelInfo, Data, str, float) -> Calculator
586    """
587    Return a model calculator using the OpenCL calculation engine.
588    """
589    if not core.HAVE_OPENCL:
590        raise RuntimeError("OpenCL not available")
591    model = core.build_model(model_info, dtype=dtype, platform="ocl")
592    calculator = DirectModel(data, model, cutoff=cutoff)
593    calculator.engine = "OCL%s"%DTYPE_MAP[dtype]
594    return calculator
595
596def eval_ctypes(model_info, data, dtype='double', cutoff=0.):
597    # type: (ModelInfo, Data, str, float) -> Calculator
598    """
599    Return a model calculator using the DLL calculation engine.
600    """
601    model = core.build_model(model_info, dtype=dtype, platform="dll")
602    calculator = DirectModel(data, model, cutoff=cutoff)
603    calculator.engine = "OMP%s"%DTYPE_MAP[dtype]
604    return calculator
605
606def time_calculation(calculator, pars, evals=1):
607    # type: (Calculator, ParameterSet, int) -> Tuple[np.ndarray, float]
608    """
609    Compute the average calculation time over N evaluations.
610
611    An additional call is generated without polydispersity in order to
612    initialize the calculation engine, and make the average more stable.
613    """
614    # initialize the code so time is more accurate
615    if evals > 1:
616        calculator(**suppress_pd(pars))
617    toc = tic()
618    # make sure there is at least one eval
619    value = calculator(**pars)
620    for _ in range(evals-1):
621        value = calculator(**pars)
622    average_time = toc()*1000. / evals
623    #print("I(q)",value)
624    return value, average_time
625
626def make_data(opts):
627    # type: (Dict[str, Any]) -> Tuple[Data, np.ndarray]
628    """
629    Generate an empty dataset, used with the model to set Q points
630    and resolution.
631
632    *opts* contains the options, with 'qmax', 'nq', 'res',
633    'accuracy', 'is2d' and 'view' parsed from the command line.
634    """
635    qmax, nq, res = opts['qmax'], opts['nq'], opts['res']
636    if opts['is2d']:
637        q = np.linspace(-qmax, qmax, nq)  # type: np.ndarray
638        data = empty_data2D(q, resolution=res)
639        data.accuracy = opts['accuracy']
640        set_beam_stop(data, 0.0004)
641        index = ~data.mask
642    else:
643        if opts['view'] == 'log' and not opts['zero']:
644            qmax = math.log10(qmax)
645            q = np.logspace(qmax-3, qmax, nq)
646        else:
647            q = np.linspace(0.001*qmax, qmax, nq)
648        if opts['zero']:
649            q = np.hstack((0, q))
650        data = empty_data1D(q, resolution=res)
651        index = slice(None, None)
652    return data, index
653
654def make_engine(model_info, data, dtype, cutoff):
655    # type: (ModelInfo, Data, str, float) -> Calculator
656    """
657    Generate the appropriate calculation engine for the given datatype.
658
659    Datatypes with '!' appended are evaluated using external C DLLs rather
660    than OpenCL.
661    """
662    if dtype == 'sasview':
663        return eval_sasview(model_info, data)
664    elif dtype.endswith('!'):
665        return eval_ctypes(model_info, data, dtype=dtype[:-1], cutoff=cutoff)
666    else:
667        return eval_opencl(model_info, data, dtype=dtype, cutoff=cutoff)
668
669def _show_invalid(data, theory):
670    # type: (Data, np.ma.ndarray) -> None
671    """
672    Display a list of the non-finite values in theory.
673    """
674    if not theory.mask.any():
675        return
676
677    if hasattr(data, 'x'):
678        bad = zip(data.x[theory.mask], theory[theory.mask])
679        print("   *** ", ", ".join("I(%g)=%g"%(x, y) for x, y in bad))
680
681
682def compare(opts, limits=None):
683    # type: (Dict[str, Any], Optional[Tuple[float, float]]) -> Tuple[float, float]
684    """
685    Preform a comparison using options from the command line.
686
687    *limits* are the limits on the values to use, either to set the y-axis
688    for 1D or to set the colormap scale for 2D.  If None, then they are
689    inferred from the data and returned. When exploring using Bumps,
690    the limits are set when the model is initially called, and maintained
691    as the values are adjusted, making it easier to see the effects of the
692    parameters.
693    """
694    limits = np.Inf, -np.Inf
695    for k in range(opts['sets']):
696        opts['pars'] = parse_pars(opts)
697        result = run_models(opts, verbose=True)
698        if opts['plot']:
699            limits = plot_models(opts, result, limits=limits, setnum=k)
700    if opts['plot']:
701        import matplotlib.pyplot as plt
702        plt.show()
703
704def run_models(opts, verbose=False):
705    # type: (Dict[str, Any]) -> Dict[str, Any]
706
707    n_base, n_comp = opts['count']
708    pars, pars2 = opts['pars']
709    data = opts['data']
710
711    # silence the linter
712    base = opts['engines'][0] if n_base else None
713    comp = opts['engines'][1] if n_comp else None
714
715    base_time = comp_time = None
716    base_value = comp_value = resid = relerr = None
717
718    # Base calculation
719    if n_base > 0:
720        try:
721            base_raw, base_time = time_calculation(base, pars, n_base)
722            base_value = np.ma.masked_invalid(base_raw)
723            if verbose:
724                print("%s t=%.2f ms, intensity=%.0f"
725                      % (base.engine, base_time, base_value.sum()))
726            _show_invalid(data, base_value)
727        except ImportError:
728            traceback.print_exc()
729            n_base = 0
730
731    # Comparison calculation
732    if n_comp > 0:
733        try:
734            comp_raw, comp_time = time_calculation(comp, pars2, n_comp)
735            comp_value = np.ma.masked_invalid(comp_raw)
736            if verbose:
737                print("%s t=%.2f ms, intensity=%.0f"
738                      % (comp.engine, comp_time, comp_value.sum()))
739            _show_invalid(data, comp_value)
740        except ImportError:
741            traceback.print_exc()
742            n_comp = 0
743
744    # Compare, but only if computing both forms
745    if n_base > 0 and n_comp > 0:
746        resid = (base_value - comp_value)
747        relerr = resid/np.where(comp_value != 0., abs(comp_value), 1.0)
748        if verbose:
749            _print_stats("|%s-%s|"
750                         % (base.engine, comp.engine) + (" "*(3+len(comp.engine))),
751                         resid)
752            _print_stats("|(%s-%s)/%s|"
753                         % (base.engine, comp.engine, comp.engine),
754                         relerr)
755
756    return dict(base_value=base_value, comp_value=comp_value,
757                base_time=base_time, comp_time=comp_time,
758                resid=resid, relerr=relerr)
759
760
761def _print_stats(label, err):
762    # type: (str, np.ma.ndarray) -> None
763    # work with trimmed data, not the full set
764    sorted_err = np.sort(abs(err.compressed()))
765    if len(sorted_err) == 0.:
766        print(label + "  no valid values")
767        return
768
769    p50 = int((len(sorted_err)-1)*0.50)
770    p98 = int((len(sorted_err)-1)*0.98)
771    data = [
772        "max:%.3e"%sorted_err[-1],
773        "median:%.3e"%sorted_err[p50],
774        "98%%:%.3e"%sorted_err[p98],
775        "rms:%.3e"%np.sqrt(np.mean(sorted_err**2)),
776        "zero-offset:%+.3e"%np.mean(sorted_err),
777        ]
778    print(label+"  "+"  ".join(data))
779
780
781def plot_models(opts, result, limits=(np.Inf, -np.Inf), setnum=0):
782    # type: (Dict[str, Any], Dict[str, Any], Optional[Tuple[float, float]]) -> Tuple[float, float]
783    base_value, comp_value= result['base_value'], result['comp_value']
784    base_time, comp_time = result['base_time'], result['comp_time']
785    resid, relerr = result['resid'], result['relerr']
786
787    have_base, have_comp = (base_value is not None), (comp_value is not None)
788    base = opts['engines'][0] if have_base else None
789    comp = opts['engines'][1] if have_comp else None
790    data = opts['data']
791    use_data = (opts['datafile'] is not None) and (have_base ^ have_comp)
792
793    # Plot if requested
794    view = opts['view']
795    import matplotlib.pyplot as plt
796    vmin, vmax = limits
797    if have_base:
798        vmin = min(vmin, base_value.min())
799        vmax = max(vmax, base_value.max())
800    if have_comp:
801        vmin = min(vmin, comp_value.min())
802        vmax = max(vmax, comp_value.max())
803    limits = vmin, vmax
804
805    if have_base:
806        if have_comp: plt.subplot(131)
807        plot_theory(data, base_value, view=view, use_data=use_data, limits=limits)
808        plt.title("%s t=%.2f ms"%(base.engine, base_time))
809        #cbar_title = "log I"
810    if have_comp:
811        if have_base: plt.subplot(132)
812        if not opts['is2d'] and have_base:
813            plot_theory(data, base_value, view=view, use_data=use_data, limits=limits)
814        plot_theory(data, comp_value, view=view, use_data=use_data, limits=limits)
815        plt.title("%s t=%.2f ms"%(comp.engine, comp_time))
816        #cbar_title = "log I"
817    if have_base and have_comp:
818        plt.subplot(133)
819        if not opts['rel_err']:
820            err, errstr, errview = resid, "abs err", "linear"
821        else:
822            err, errstr, errview = abs(relerr), "rel err", "log"
823        if 0:  # 95% cutoff
824            sorted = np.sort(err.flatten())
825            cutoff = sorted[int(sorted.size*0.95)]
826            err[err>cutoff] = cutoff
827        #err,errstr = base/comp,"ratio"
828        plot_theory(data, None, resid=err, view=errview, use_data=use_data)
829        if view == 'linear':
830            plt.xscale('linear')
831        plt.title("max %s = %.3g"%(errstr, abs(err).max()))
832        #cbar_title = errstr if errview=="linear" else "log "+errstr
833    #if is2D:
834    #    h = plt.colorbar()
835    #    h.ax.set_title(cbar_title)
836    fig = plt.gcf()
837    extra_title = ' '+opts['title'] if opts['title'] else ''
838    fig.suptitle(":".join(opts['name']) + extra_title)
839
840    if have_base and have_comp and opts['show_hist']:
841        plt.figure()
842        v = relerr
843        v[v == 0] = 0.5*np.min(np.abs(v[v != 0]))
844        plt.hist(np.log10(np.abs(v)), normed=1, bins=50)
845        plt.xlabel('log10(err), err = |(%s - %s) / %s|'
846                   % (base.engine, comp.engine, comp.engine))
847        plt.ylabel('P(err)')
848        plt.title('Distribution of relative error between calculation engines')
849
850    return limits
851
852
853# ===========================================================================
854#
855NAME_OPTIONS = set([
856    'plot', 'noplot',
857    'half', 'fast', 'single', 'double',
858    'single!', 'double!', 'quad!', 'sasview',
859    'lowq', 'midq', 'highq', 'exq', 'zero',
860    '2d', '1d',
861    'preset', 'random',
862    'poly', 'mono',
863    'magnetic', 'nonmagnetic',
864    'nopars', 'pars',
865    'rel', 'abs',
866    'linear', 'log', 'q4',
867    'hist', 'nohist',
868    'edit', 'html', 'help',
869    'demo', 'default',
870    ])
871VALUE_OPTIONS = [
872    # Note: random is both a name option and a value option
873    'cutoff', 'random', 'nq', 'res', 'accuracy', 'title', 'data', 'sets'
874    ]
875
876def columnize(items, indent="", width=79):
877    # type: (List[str], str, int) -> str
878    """
879    Format a list of strings into columns.
880
881    Returns a string with carriage returns ready for printing.
882    """
883    column_width = max(len(w) for w in items) + 1
884    num_columns = (width - len(indent)) // column_width
885    num_rows = len(items) // num_columns
886    items = items + [""] * (num_rows * num_columns - len(items))
887    columns = [items[k*num_rows:(k+1)*num_rows] for k in range(num_columns)]
888    lines = [" ".join("%-*s"%(column_width, entry) for entry in row)
889             for row in zip(*columns)]
890    output = indent + ("\n"+indent).join(lines)
891    return output
892
893
894def get_pars(model_info, use_demo=False):
895    # type: (ModelInfo, bool) -> ParameterSet
896    """
897    Extract demo parameters from the model definition.
898    """
899    # Get the default values for the parameters
900    pars = {}
901    for p in model_info.parameters.call_parameters:
902        parts = [('', p.default)]
903        if p.polydisperse:
904            parts.append(('_pd', 0.0))
905            parts.append(('_pd_n', 0))
906            parts.append(('_pd_nsigma', 3.0))
907            parts.append(('_pd_type', "gaussian"))
908        for ext, val in parts:
909            if p.length > 1:
910                dict(("%s%d%s" % (p.id, k, ext), val)
911                     for k in range(1, p.length+1))
912            else:
913                pars[p.id + ext] = val
914
915    # Plug in values given in demo
916    if use_demo:
917        pars.update(model_info.demo)
918    return pars
919
920INTEGER_RE = re.compile("^[+-]?[1-9][0-9]*$")
921def isnumber(str):
922    match = FLOAT_RE.match(str)
923    isfloat = (match and not str[match.end():])
924    return isfloat or INTEGER_RE.match(str)
925
926# For distinguishing pairs of models for comparison
927# key-value pair separator =
928# shell characters  | & ; <> $ % ' " \ # `
929# model and parameter names _
930# parameter expressions - + * / . ( )
931# path characters including tilde expansion and windows drive ~ / :
932# not sure about brackets [] {}
933# maybe one of the following @ ? ^ ! ,
934MODEL_SPLIT = ','
935def parse_opts(argv):
936    # type: (List[str]) -> Dict[str, Any]
937    """
938    Parse command line options.
939    """
940    MODELS = core.list_models()
941    flags = [arg for arg in argv
942             if arg.startswith('-')]
943    values = [arg for arg in argv
944              if not arg.startswith('-') and '=' in arg]
945    positional_args = [arg for arg in argv
946                       if not arg.startswith('-') and '=' not in arg]
947    models = "\n    ".join("%-15s"%v for v in MODELS)
948    if len(positional_args) == 0:
949        print(USAGE)
950        print("\nAvailable models:")
951        print(columnize(MODELS, indent="  "))
952        return None
953    if len(positional_args) > 3:
954        print("expected parameters: model N1 N2")
955
956    invalid = [o[1:] for o in flags
957               if o[1:] not in NAME_OPTIONS
958               and not any(o.startswith('-%s='%t) for t in VALUE_OPTIONS)]
959    if invalid:
960        print("Invalid options: %s"%(", ".join(invalid)))
961        return None
962
963    name = positional_args[0]
964    n1 = int(positional_args[1]) if len(positional_args) > 1 else 1
965    n2 = int(positional_args[2]) if len(positional_args) > 2 else 1
966
967    # pylint: disable=bad-whitespace
968    # Interpret the flags
969    opts = {
970        'plot'      : True,
971        'view'      : 'log',
972        'is2d'      : False,
973        'qmax'      : 0.05,
974        'nq'        : 128,
975        'res'       : 0.0,
976        'accuracy'  : 'Low',
977        'cutoff'    : 0.0,
978        'seed'      : -1,  # default to preset
979        'mono'      : True,
980        # Default to magnetic a magnetic moment is set on the command line
981        'magnetic'  : False,
982        'show_pars' : False,
983        'show_hist' : False,
984        'rel_err'   : True,
985        'explore'   : False,
986        'use_demo'  : True,
987        'zero'      : False,
988        'html'      : False,
989        'title'     : None,
990        'datafile'  : None,
991        'sets'      : 1,
992    }
993    engines = []
994    for arg in flags:
995        if arg == '-noplot':    opts['plot'] = False
996        elif arg == '-plot':    opts['plot'] = True
997        elif arg == '-linear':  opts['view'] = 'linear'
998        elif arg == '-log':     opts['view'] = 'log'
999        elif arg == '-q4':      opts['view'] = 'q4'
1000        elif arg == '-1d':      opts['is2d'] = False
1001        elif arg == '-2d':      opts['is2d'] = True
1002        elif arg == '-exq':     opts['qmax'] = 10.0
1003        elif arg == '-highq':   opts['qmax'] = 1.0
1004        elif arg == '-midq':    opts['qmax'] = 0.2
1005        elif arg == '-lowq':    opts['qmax'] = 0.05
1006        elif arg == '-zero':    opts['zero'] = True
1007        elif arg.startswith('-nq='):       opts['nq'] = int(arg[4:])
1008        elif arg.startswith('-res='):      opts['res'] = float(arg[5:])
1009        elif arg.startswith('-sets='):     opts['sets'] = int(arg[6:])
1010        elif arg.startswith('-accuracy='): opts['accuracy'] = arg[10:]
1011        elif arg.startswith('-cutoff='):   opts['cutoff'] = float(arg[8:])
1012        elif arg.startswith('-random='):   opts['seed'] = int(arg[8:])
1013        elif arg.startswith('-title='):    opts['title'] = arg[7:]
1014        elif arg.startswith('-data='):     opts['datafile'] = arg[6:]
1015        elif arg == '-random':  opts['seed'] = np.random.randint(1000000)
1016        elif arg == '-preset':  opts['seed'] = -1
1017        elif arg == '-mono':    opts['mono'] = True
1018        elif arg == '-poly':    opts['mono'] = False
1019        elif arg == '-magnetic':       opts['magnetic'] = True
1020        elif arg == '-nonmagnetic':    opts['magnetic'] = False
1021        elif arg == '-pars':    opts['show_pars'] = True
1022        elif arg == '-nopars':  opts['show_pars'] = False
1023        elif arg == '-hist':    opts['show_hist'] = True
1024        elif arg == '-nohist':  opts['show_hist'] = False
1025        elif arg == '-rel':     opts['rel_err'] = True
1026        elif arg == '-abs':     opts['rel_err'] = False
1027        elif arg == '-half':    engines.append(arg[1:])
1028        elif arg == '-fast':    engines.append(arg[1:])
1029        elif arg == '-single':  engines.append(arg[1:])
1030        elif arg == '-double':  engines.append(arg[1:])
1031        elif arg == '-single!': engines.append(arg[1:])
1032        elif arg == '-double!': engines.append(arg[1:])
1033        elif arg == '-quad!':   engines.append(arg[1:])
1034        elif arg == '-sasview': engines.append(arg[1:])
1035        elif arg == '-edit':    opts['explore'] = True
1036        elif arg == '-demo':    opts['use_demo'] = True
1037        elif arg == '-default':    opts['use_demo'] = False
1038        elif arg == '-html':    opts['html'] = True
1039        elif arg == '-help':    opts['html'] = True
1040    # pylint: enable=bad-whitespace
1041
1042    # Force random if more than one set
1043    if opts['sets'] > 1 and opts['seed'] < 0:
1044        opts['seed'] = np.random.randint(1000000)
1045
1046    if MODEL_SPLIT in name:
1047        name, name2 = name.split(MODEL_SPLIT, 2)
1048    else:
1049        name2 = name
1050    try:
1051        model_info = core.load_model_info(name)
1052        model_info2 = core.load_model_info(name2) if name2 != name else model_info
1053    except ImportError as exc:
1054        print(str(exc))
1055        print("Could not find model; use one of:\n    " + models)
1056        return None
1057
1058    # TODO: check if presets are different when deciding if models are same
1059    same_model = name == name2
1060    if len(engines) == 0:
1061        if same_model:
1062            engines.extend(['single', 'double'])
1063        else:
1064            engines.extend(['single', 'single'])
1065    elif len(engines) == 1:
1066        if not same_model:
1067            engines.append(engines[0])
1068        elif engines[0] == 'double':
1069            engines.append('single')
1070        else:
1071            engines.append('double')
1072    elif len(engines) > 2:
1073        del engines[2:]
1074
1075    # Create the computational engines
1076    if opts['datafile'] is not None:
1077        data = load_data(os.path.expanduser(opts['datafile']))
1078    else:
1079        data, _ = make_data(opts)
1080    if n1:
1081        base = make_engine(model_info, data, engines[0], opts['cutoff'])
1082    else:
1083        base = None
1084    if n2:
1085        comp = make_engine(model_info2, data, engines[1], opts['cutoff'])
1086    else:
1087        comp = None
1088
1089    # pylint: disable=bad-whitespace
1090    # Remember it all
1091    opts.update({
1092        'data'      : data,
1093        'name'      : [name, name2],
1094        'def'       : [model_info, model_info2],
1095        'count'     : [n1, n2],
1096        'engines'   : [base, comp],
1097        'values'    : values,
1098    })
1099    # pylint: enable=bad-whitespace
1100
1101    return opts
1102
1103def parse_pars(opts):
1104    model_info, model_info2 = opts['def']
1105
1106    # Get demo parameters from model definition, or use default parameters
1107    # if model does not define demo parameters
1108    pars = get_pars(model_info, opts['use_demo'])
1109    pars2 = get_pars(model_info2, opts['use_demo'])
1110    pars2.update((k, v) for k, v in pars.items() if k in pars2)
1111    # randomize parameters
1112    #pars.update(set_pars)  # set value before random to control range
1113    if opts['seed'] > -1:
1114        pars = randomize_pars(model_info, pars)
1115        if model_info != model_info2:
1116            pars2 = randomize_pars(model_info2, pars2)
1117            # Share values for parameters with the same name
1118            for k, v in pars.items():
1119                if k in pars2:
1120                    pars2[k] = v
1121        else:
1122            pars2 = pars.copy()
1123        constrain_pars(model_info, pars)
1124        constrain_pars(model_info2, pars2)
1125    if opts['mono']:
1126        pars = suppress_pd(pars)
1127        pars2 = suppress_pd(pars2)
1128    if not opts['magnetic']:
1129        pars = suppress_magnetism(pars)
1130        pars2 = suppress_magnetism(pars2)
1131
1132    # Fill in parameters given on the command line
1133    presets = {}
1134    presets2 = {}
1135    for arg in opts['values']:
1136        k, v = arg.split('=', 1)
1137        if k not in pars and k not in pars2:
1138            # extract base name without polydispersity info
1139            s = set(p.split('_pd')[0] for p in pars)
1140            print("%r invalid; parameters are: %s"%(k, ", ".join(sorted(s))))
1141            return None
1142        v1, v2 = v.split(MODEL_SPLIT, 2) if MODEL_SPLIT in v else (v,v)
1143        if v1 and k in pars:
1144            presets[k] = float(v1) if isnumber(v1) else v1
1145        if v2 and k in pars2:
1146            presets2[k] = float(v2) if isnumber(v2) else v2
1147
1148    # If pd given on the command line, default pd_n to 35
1149    for k, v in list(presets.items()):
1150        if k.endswith('_pd'):
1151            presets.setdefault(k+'_n', 35.)
1152    for k, v in list(presets2.items()):
1153        if k.endswith('_pd'):
1154            presets2.setdefault(k+'_n', 35.)
1155
1156    # Evaluate preset parameter expressions
1157    context = MATH.copy()
1158    context['np'] = np
1159    context.update(pars)
1160    context.update((k, v) for k, v in presets.items() if isinstance(v, float))
1161    for k, v in presets.items():
1162        if not isinstance(v, float) and not k.endswith('_type'):
1163            presets[k] = eval(v, context)
1164    context.update(presets)
1165    context.update((k, v) for k, v in presets2.items() if isinstance(v, float))
1166    for k, v in presets2.items():
1167        if not isinstance(v, float) and not k.endswith('_type'):
1168            presets2[k] = eval(v, context)
1169
1170    # update parameters with presets
1171    pars.update(presets)  # set value after random to control value
1172    pars2.update(presets2)  # set value after random to control value
1173    #import pprint; pprint.pprint(model_info)
1174
1175    if opts['show_pars']:
1176        if model_info.name != model_info2.name or pars != pars2:
1177            print("==== %s ====="%model_info.name)
1178            print(str(parlist(model_info, pars, opts['is2d'])))
1179            print("==== %s ====="%model_info2.name)
1180            print(str(parlist(model_info2, pars2, opts['is2d'])))
1181        else:
1182            print(str(parlist(model_info, pars, opts['is2d'])))
1183
1184    return pars, pars2
1185
1186def show_docs(opts):
1187    # type: (Dict[str, Any]) -> None
1188    """
1189    show html docs for the model
1190    """
1191    import os
1192    from .generate import make_html
1193    from . import rst2html
1194
1195    info = opts['def'][0]
1196    html = make_html(info)
1197    path = os.path.dirname(info.filename)
1198    url = "file://"+path.replace("\\","/")[2:]+"/"
1199    rst2html.view_html_qtapp(html, url)
1200
1201def explore(opts):
1202    # type: (Dict[str, Any]) -> None
1203    """
1204    explore the model using the bumps gui.
1205    """
1206    import wx  # type: ignore
1207    from bumps.names import FitProblem  # type: ignore
1208    from bumps.gui.app_frame import AppFrame  # type: ignore
1209    from bumps.gui import signal
1210
1211    is_mac = "cocoa" in wx.version()
1212    # Create an app if not running embedded
1213    app = wx.App() if wx.GetApp() is None else None
1214    model = Explore(opts)
1215    problem = FitProblem(model)
1216    frame = AppFrame(parent=None, title="explore", size=(1000, 700))
1217    if not is_mac:
1218        frame.Show()
1219    frame.panel.set_model(model=problem)
1220    frame.panel.Layout()
1221    frame.panel.aui.Split(0, wx.TOP)
1222    def reset_parameters(event):
1223        model.revert_values()
1224        signal.update_parameters(problem)
1225    frame.Bind(wx.EVT_TOOL, reset_parameters, frame.ToolBar.GetToolByPos(1))
1226    if is_mac: frame.Show()
1227    # If running withing an app, start the main loop
1228    if app:
1229        app.MainLoop()
1230
1231class Explore(object):
1232    """
1233    Bumps wrapper for a SAS model comparison.
1234
1235    The resulting object can be used as a Bumps fit problem so that
1236    parameters can be adjusted in the GUI, with plots updated on the fly.
1237    """
1238    def __init__(self, opts):
1239        # type: (Dict[str, Any]) -> None
1240        from bumps.cli import config_matplotlib  # type: ignore
1241        from . import bumps_model
1242        config_matplotlib()
1243        self.opts = opts
1244        opts['pars'] = list(opts['pars'])
1245        p1, p2 = opts['pars']
1246        m1, m2 = opts['def']
1247        self.fix_p2 = m1 != m2 or p1 != p2
1248        model_info = m1
1249        pars, pd_types = bumps_model.create_parameters(model_info, **p1)
1250        # Initialize parameter ranges, fixing the 2D parameters for 1D data.
1251        if not opts['is2d']:
1252            for p in model_info.parameters.user_parameters({}, is2d=False):
1253                for ext in ['', '_pd', '_pd_n', '_pd_nsigma']:
1254                    k = p.name+ext
1255                    v = pars.get(k, None)
1256                    if v is not None:
1257                        v.range(*parameter_range(k, v.value))
1258        else:
1259            for k, v in pars.items():
1260                v.range(*parameter_range(k, v.value))
1261
1262        self.pars = pars
1263        self.starting_values = dict((k, v.value) for k, v in pars.items())
1264        self.pd_types = pd_types
1265        self.limits = np.Inf, -np.Inf
1266
1267    def revert_values(self):
1268        for k, v in self.starting_values.items():
1269            self.pars[k].value = v
1270
1271    def model_update(self):
1272        pass
1273
1274    def numpoints(self):
1275        # type: () -> int
1276        """
1277        Return the number of points.
1278        """
1279        return len(self.pars) + 1  # so dof is 1
1280
1281    def parameters(self):
1282        # type: () -> Any   # Dict/List hierarchy of parameters
1283        """
1284        Return a dictionary of parameters.
1285        """
1286        return self.pars
1287
1288    def nllf(self):
1289        # type: () -> float
1290        """
1291        Return cost.
1292        """
1293        # pylint: disable=no-self-use
1294        return 0.  # No nllf
1295
1296    def plot(self, view='log'):
1297        # type: (str) -> None
1298        """
1299        Plot the data and residuals.
1300        """
1301        pars = dict((k, v.value) for k, v in self.pars.items())
1302        pars.update(self.pd_types)
1303        self.opts['pars'][0] = pars
1304        if not self.fix_p2:
1305            self.opts['pars'][1] = pars
1306        result = run_models(self.opts)
1307        limits = plot_models(self.opts, result, limits=self.limits)
1308        if self.limits is None:
1309            vmin, vmax = limits
1310            self.limits = vmax*1e-7, 1.3*vmax
1311            import pylab; pylab.clf()
1312            plot_models(self.opts, result, limits=self.limits)
1313
1314
1315def main(*argv):
1316    # type: (*str) -> None
1317    """
1318    Main program.
1319    """
1320    opts = parse_opts(argv)
1321    if opts['seed'] > -1:
1322        print("Randomize using -random=%i"%opts['seed'])
1323        np.random.seed(opts['seed'])
1324    if opts is not None:
1325        if opts['html']:
1326            show_docs(opts)
1327        elif opts['explore']:
1328            opts['pars'] = parse_pars(opts)
1329            explore(opts)
1330        else:
1331            compare(opts)
1332
1333if __name__ == "__main__":
1334    main(*sys.argv[1:])
Note: See TracBrowser for help on using the repository browser.