Index: .travis.yml
===================================================================
--- .travis.yml	(revision 2d09df1a016d5aba6f689a4b863e9e4314756ac0)
+++ .travis.yml	(revision 335271e7aa66a7275b4a987d7b763a60ff47493b)
@@ -37,11 +37,13 @@
 - conda update --yes conda
 - conda info -a
-- conda install --yes python=$PY numpy scipy cython mako cffi
+- conda install --yes python=$PY numpy scipy matplotlib docutils setuptools pytest
 install:
 - pip install bumps
-- pip install unittest-xml-reporting
+- pip install unittest-xml-reporting tinycc
 script:
 - python --version
-- python -m sasmodels.model_test -v dll all
+#- python -m sasmodels.model_test -v dll all
+#- python -m pytest -v --cache-clear
+- python setup.py test --pytest-args -v
 before_deploy:
 - echo -e "Host danse.chem.utk.edu\n\tStrictHostKeyChecking no\n" >> ~/.ssh/config
Index: appveyor.yml
===================================================================
--- appveyor.yml	(revision 1258e328b2c5762f39bbbe6e5150dc83f9109814)
+++ appveyor.yml	(revision 335271e7aa66a7275b4a987d7b763a60ff47493b)
@@ -4,5 +4,6 @@
   # /E:ON and /V:ON options are not enabled in the batch script interpreter
   # See: http://stackoverflow.com/a/13751649/163740
-  CMD_IN_ENV: "cmd /E:ON /V:ON /C obvci_appveyor_python_build_env.cmd"
+  # 2018-01-19 PAK: probably irrelevant now that we are using tinycc rather than msvc
+  #CMD_IN_ENV: "cmd /E:ON /V:ON /C obvci_appveyor_python_build_env.cmd"
 
   # Workaround for https://github.com/conda/conda-build/issues/636
@@ -22,5 +23,5 @@
     - TARGET_ARCH: "x86"
       CONDA_PY: "35"
-      PY_CONDITION: "python >=3.5"
+      PY_CONDITION: "python >=3.5,<3.6"
       CONDA_INSTALL_LOCN: "C:\\Miniconda35"
     - TARGET_ARCH: "x64"
@@ -34,5 +35,5 @@
     - TARGET_ARCH: "x64"
       CONDA_PY: "35"
-      PY_CONDITION: "python >=3.5"
+      PY_CONDITION: "python >=3.5,<3.6"
       CONDA_INSTALL_LOCN: "C:\\Miniconda35-x64"
 
@@ -43,9 +44,14 @@
 
 install:
-    # Set the CONDA_NPY, although it has no impact on the actual build. We need this because of a test within conda-build.
+    # Set the CONDA_NPY, although it has no impact on the actual build.
+    # We need this because of a test within conda-build.
     - cmd: set CONDA_NPY=19
 
     # Remove cygwin (and therefore the git that comes with it).
-    - cmd: rmdir C:\cygwin /s /q
+    # 2018-01-19 PAK: irrelevant since we already pulled the repo
+    #- cmd: rmdir C:\cygwin /s /q
+
+    # Set the conda path; would be nice to do this
+    - cmd: path %CONDA_INSTALL_LOCN%\Scripts;%PATH%
 
     # Use the pre-installed Miniconda for the desired arch
@@ -56,23 +62,30 @@
     # so that we can update it. Then we remove it so that
     # we can do a proper activation.
-    - cmd: set "OLDPATH=%PATH%"
-    - cmd: set "PATH=%CONDA_INSTALL_LOCN%\\Scripts;%CONDA_INSTALL_LOCN%\\Library\\bin;%PATH%"
     - cmd: conda update --yes --quiet conda python
-    - cmd: set "PATH=%OLDPATH%"
     - cmd: call %CONDA_INSTALL_LOCN%\Scripts\activate.bat
-
-    - cmd: conda config --add channels conda-forge
-    - cmd: conda config --set show_channel_urls true
-    - cmd: conda update --yes --quiet conda
-    - cmd: conda install --yes --quiet obvious-ci
-    - cmd: conda install --yes --quiet numpy toolchain scipy cython cffi
+    #- cmd: conda config --add channels conda-forge
+    #- cmd: conda config --set show_channel_urls true
+    #- cmd: conda install --yes --quiet obvious-ci
+    # 2018-01-19 PAK: skipping toolchain cython and cffi (these were for pyopencl?)
+    - cmd: conda install --yes --quiet numpy scipy matplotlib docutils setuptools pytest
+    # 2018-01-19 PAK: skipping pyopencl; this would be needed for deploy but maybe not for test
     #- cmd: conda install --yes --channel conda-forge pyopencl
+    # 2018-01-19 PAK: 3rd party packages might need msvc, so %CMD_IN_ENV% may be needed for pip
     - cmd: pip install bumps unittest-xml-reporting tinycc
 
 build_script:
     # Build the project
-    - "%CMD_IN_ENV% python setup.py build"
+    # 2018-01-19 PAK: maybe need one of this if using msvc?
+    #- "%CMD_IN_ENV% python setup.py build"
+    #- cmd /E:ON /V:ON /C obvci_appveyor_python_build_env.cmd python setup.py build
+    - python setup.py build
 
 test_script:
     # Run the project tests
-    - "%CMD_IN_ENV% python -m sasmodels.model_test dll all"
+    # 2018-01-19 PAK: maybe need one of this if using msvc?
+    #- "%CMD_IN_ENV% python -m sasmodels.model_test dll all"
+    #- cmd /E:ON /V:ON /C obvci_appveyor_python_build_env.cmd python -m sasmoels.model_test dll all
+    #- python -m sasmodels.model_test dll all
+    #- nosetests -v sasmodels/*.py
+    #- python -m pytest -v --cache-clear
+    - python setup.py test --pytest-args -v
Index: conftest.py
===================================================================
--- conftest.py	(revision f6fd413b32398bc181650cdaf712d5f2981dfc85)
+++ conftest.py	(revision f6fd413b32398bc181650cdaf712d5f2981dfc85)
@@ -0,0 +1,119 @@
+"""
+pytest hooks for sasmodels
+
+Hooks for running sasmodels tests via pytest.
+
+*pytest_collection_modifyitems* adds the test description to the end of
+the test name.  This is needed for the generated list of tests is sasmodels,
+where each test has a description giving the name of the model.  For example
+"model_tests::[3]" becomes "model_tests::[3]::bcc_paracrystal-dll".  Need to
+leave the "::[3]" in the name since that is the way you can indicate this
+test specifically from the pytest command line. [This is perhaps because
+the modifyitems hook is only called after test selection.]
+
+*pytest_ignore_collect* skips kernelcl.py if pyopencl cannot be imported.
+"""
+from __future__ import print_function
+
+import os.path
+import inspect
+
+import pytest
+from _pytest.unittest import TestCaseFunction
+
+def pytest_pycollect_makeitem(collector, name, obj):
+    """
+    Convert test generator into list of function tests so that pytest doesn't
+    complain about deprecated yield tests.
+
+    Note that unlike nose, the tests are generated and saved instead of run
+    immediately.  This means that any dynamic context, such as a for-loop
+    variable, must be captured by wrapping the yield result in a function call.
+
+    For example::
+
+        for value in 1, 2, 3:
+            for test in test_cases:
+                yield test, value
+
+    will need to be changed to::
+
+        def build_test(test, value):
+            return test, value
+        for value in 1, 2, 3:
+            for test in test_cases:
+                yield build_test(test, value)
+
+    This allows the context (test and value) to be captured by lexical closure
+    in build_test. See https://stackoverflow.com/a/233835/6195051.
+    """
+    if collector.istestfunction(obj, name) and is_generator(obj):
+        tests = []
+        for number, yielded in enumerate(obj()):
+            index, call, args = split_yielded_test(yielded, number)
+            test = pytest.Function(name+index, collector, args=args, callobj=call)
+            tests.append(test)
+        return tests
+
+def is_generator(func):
+    """
+    Returns True if function has yield.
+    """
+    # Cribbed from _pytest.compat is_generator and iscoroutinefunction; these
+    # may not be available as part of pytest 4.
+    coroutine = (getattr(func, '_is_coroutine', False) or
+                 getattr(inspect, 'iscoroutinefunction', lambda f: False)(func))
+    generator = inspect.isgeneratorfunction(func)
+    return generator and not coroutine
+
+def split_yielded_test(obj, number):
+    if not isinstance(obj, (tuple, list)):
+        obj = (obj,)
+    if not callable(obj[0]):
+        index = "['%s']"%obj[0]
+        obj = obj[1:]
+    else:
+        index = "[%d]"%number
+    call, args = obj[0], obj[1:]
+    return index, call, args
+
+USE_DOCSTRING_AS_DESCRIPTION = True
+def pytest_collection_modifyitems(session, config, items):
+    """
+    Add description to the test node id if item is a function and function
+    has a description attribute or __doc__ attribute.
+    """
+    for item in items:
+        if isinstance(item, pytest.Function):
+            if isinstance(item, TestCaseFunction):
+                # TestCase uses item.name to find the method so skip
+                continue
+            function = item.obj
+
+            # If the test case provides a "description" attribute then use it
+            # as an extended description.  If there is no description attribute,
+            # then perhaps use the test docstring.
+            if USE_DOCSTRING_AS_DESCRIPTION:
+                description = getattr(function, 'description', function.__doc__)
+            else:
+                description = getattr(function, 'description', "")
+
+            # If description is not supplied but yield args are, then use the
+            # yield args for the description
+            if not description and getattr(item, '_args', ()):
+                description = (str(item._args) if len(item._args) > 1
+                               else str(item._args[0]))
+
+            # Set the description as part of the node identifier.
+            if description:
+                # Strip spaces from start and end and strip dots from end
+                # pytest converts '.' to '::' on output for some reason.
+                description = description.strip().rstrip('.')
+                # Join multi-line descriptions into a single line
+                if '\n' in description:
+                    description = " ".join(line.strip()
+                                           for line in description.split('\n'))
+
+                # Note: leave the current name mostly as-is since the prefix
+                # is needed to specify the nth test from a list of tests.
+                item.name += "::" + description
Index: doc/developer/overview.rst
===================================================================
--- doc/developer/overview.rst	(revision 2a7e20e9c4ba72da5c6088e743a64f3345ee554d)
+++ doc/developer/overview.rst	(revision 0d5a655d6ddebe6096fad050347df4796b956767)
@@ -190,5 +190,5 @@
 *sasmodels.generate.PROJECTION*, with the default *PROJECTION=1* for
 equirectangular and *PROJECTION=2* for sinusoidal.  The more complicated
-Guyou and Postel projections are not implemented. See explore.jitter.draw_mesh
+Guyou and Postel projections are not implemented. See jitter.draw_mesh
 for details.
 
@@ -274,6 +274,6 @@
 code.
 
-*explore/jitter.py* is for exploring different options for handling
-orientation and orientation dispersity.  It uses *explore/guyou.py* to
+*sasmodels/jitter.py* is for exploring different options for handling
+orientation and orientation dispersity.  It uses *sasmodels/guyou.py* to
 generate the Guyou projection.
 
Index: doc/genapi.py
===================================================================
--- doc/genapi.py	(revision 706f46620c115bdbc319d902ba7678c9d247d6d0)
+++ doc/genapi.py	(revision 0d5a655d6ddebe6096fad050347df4796b956767)
@@ -69,4 +69,6 @@
     ('exception', 'Annotate exceptions'),
     ('generate', 'Model parser'),
+    ('jitter', 'Orientation explorer'),
+    ('guyou', 'Guyou map projection'),
     ('kernel', 'Evaluator type definitions'),
     ('kernelcl', 'OpenCL model evaluator'),
Index: doc/guide/orientation/orientation.rst
===================================================================
--- doc/guide/orientation/orientation.rst	(revision 5fb0634b45bba451ca824eecf40346b1ed5305a7)
+++ doc/guide/orientation/orientation.rst	(revision 0d5a655d6ddebe6096fad050347df4796b956767)
@@ -52,4 +52,11 @@
 yaw angle $\theta$ about the $b$-axis and pitch angle $\phi$ about the
 $a$-axis.
+
+You can explore the view and jitter angles interactively using
+:func:`sasmodels.jitter.run`.  Enter the following into the python
+interpreter::
+
+    from sasmodels import jitter
+    jitter.run()
 
 More formally, starting with axes $a$-$b$-$c$ of the particle aligned
Index: doc/guide/pd/polydispersity.rst
===================================================================
--- doc/guide/pd/polydispersity.rst	(revision eda8b306f3ccd288f34d3e66c7fcf5754162bb2c)
+++ doc/guide/pd/polydispersity.rst	(revision 29afc50fdd56895a3c14d9c1e09b002d087c956a)
@@ -23,13 +23,21 @@
 average over the size distribution.
 
-Each distribution is characterized by its center $\bar x$, its width $\sigma$,
-the number of sigmas $N_\sigma$ to include from the tails, and the number of
-points used to compute the average. The center of the distribution is set by the
-value of the model parameter.  Volume parameters have polydispersity *PD*
-(not to be confused with a molecular weight distributions in polymer science)
-leading to a size distribution of width $\text{PD} = \sigma / \bar x$, but
-orientation parameters use an angular distributions of width $\sigma$.
-$N_\sigma$ determines how far into the tails to evaluate the distribution, with
-larger values of $N_\sigma$ required for heavier tailed distributions.
+Each distribution is characterized by a center value $\bar x$ or
+$x_\text{med}$, a width parameter $\sigma$ (note this is *not necessarily*
+the standard deviation, so read the description carefully), the number of
+sigmas $N_\sigma$ to include from the tails of the distribution, and the
+number of points used to compute the average. The center of the distribution
+is set by the value of the model parameter. The meaning of a polydispersity 
+parameter *PD* (not to be confused with a molecular weight distributions 
+in polymer science) in a model depends on the type of parameter it is being 
+applied too.
+
+The distribution width applied to *volume* (ie, shape-describing) parameters 
+is relative to the center value such that $\sigma = \mathrm{PD} \cdot \bar x$. 
+However, the distribution width applied to *orientation* (ie, angle-describing) 
+parameters is just $\sigma = \mathrm{PD}$.
+
+$N_\sigma$ determines how far into the tails to evaluate the distribution,
+with larger values of $N_\sigma$ required for heavier tailed distributions.
 The scattering in general falls rapidly with $qr$ so the usual assumption
 that $G(r - 3\sigma_r)$ is tiny and therefore $f(r - 3\sigma_r)G(r - 3\sigma_r)$
@@ -42,8 +50,10 @@
 calculations are generally more robust with more data points or more angles.
 
-The following five distribution functions are provided:
-
+The following distribution functions are provided:
+
+*  *Uniform Distribution*
 *  *Rectangular Distribution*
 *  *Gaussian Distribution*
+*  *Boltzmann Distribution*
 *  *Lognormal Distribution*
 *  *Schulz Distribution*
@@ -52,4 +62,50 @@
 These are all implemented as *number-average* distributions.
 
+Additional distributions are under consideration.
+
+Suggested Applications
+^^^^^^^^^^^^^^^^^^^^^^
+
+If applying polydispersion to parameters describing particle sizes, use
+the Lognormal or Schulz distributions.
+
+If applying polydispersion to parameters describing interfacial thicknesses
+or angular orientations, use the Gaussian or Boltzmann distributions.
+
+If applying polydispersion to parameters describing angles, use the Uniform 
+distribution. Beware of using distributions that are always positive (eg, the 
+Lognormal) because angles can be negative!
+
+The array distribution allows a user-defined distribution to be applied.
+
+.. ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ
+
+Uniform Distribution
+^^^^^^^^^^^^^^^^^^^^
+
+The Uniform Distribution is defined as
+
+.. math::
+
+    f(x) = \frac{1}{\text{Norm}}
+    \begin{cases}
+        1 & \text{for } |x - \bar x| \leq \sigma \\
+        0 & \text{for } |x - \bar x| > \sigma
+    \end{cases}
+
+where $\bar x$ ($x_\text{mean}$ in the figure) is the mean of the
+distribution, $\sigma$ is the half-width, and *Norm* is a normalization
+factor which is determined during the numerical calculation.
+
+The polydispersity in sasmodels is given by
+
+.. math:: \text{PD} = \sigma / \bar x
+
+.. figure:: pd_uniform.jpg
+
+    Uniform distribution.
+
+The value $N_\sigma$ is ignored for this distribution.
+
 .. ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ
 
@@ -63,11 +119,11 @@
     f(x) = \frac{1}{\text{Norm}}
     \begin{cases}
-      1 & \text{for } |x - \bar x| \leq w \\
-      0 & \text{for } |x - \bar x| > w
+        1 & \text{for } |x - \bar x| \leq w \\
+        0 & \text{for } |x - \bar x| > w
     \end{cases}
 
-where $\bar x$ is the mean of the distribution, $w$ is the half-width, and
-*Norm* is a normalization factor which is determined during the numerical
-calculation.
+where $\bar x$ ($x_\text{mean}$ in the figure) is the mean of the
+distribution, $w$ is the half-width, and *Norm* is a normalization
+factor which is determined during the numerical calculation.
 
 Note that the standard deviation and the half width $w$ are different!
@@ -77,5 +133,5 @@
 .. math:: \sigma = w / \sqrt{3}
 
-whilst the polydispersity is
+whilst the polydispersity in sasmodels is given by
 
 .. math:: \text{PD} = \sigma / \bar x
@@ -84,4 +140,8 @@
 
     Rectangular distribution.
+
+.. note:: The Rectangular Distribution is deprecated in favour of the
+            Uniform Distribution above and is described here for backwards
+            compatibility with earlier versions of SasView only.
 
 .. ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ
@@ -95,10 +155,11 @@
 
     f(x) = \frac{1}{\text{Norm}}
-           \exp\left(-\frac{(x - \bar x)^2}{2\sigma^2}\right)
-
-where $\bar x$ is the mean of the distribution and *Norm* is a normalization
-factor which is determined during the numerical calculation.
-
-The polydispersity is
+            \exp\left(-\frac{(x - \bar x)^2}{2\sigma^2}\right)
+
+where $\bar x$ ($x_\text{mean}$ in the figure) is the mean of the
+distribution and *Norm* is a normalization factor which is determined
+during the numerical calculation.
+
+The polydispersity in sasmodels is given by
 
 .. math:: \text{PD} = \sigma / \bar x
@@ -107,4 +168,31 @@
 
     Normal distribution.
+
+.. ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ
+
+Boltzmann Distribution
+^^^^^^^^^^^^^^^^^^^^^^
+
+The Boltzmann Distribution is defined as
+
+.. math::
+
+    f(x) = \frac{1}{\text{Norm}}
+            \exp\left(-\frac{ | x - \bar x | }{\sigma}\right)
+
+where $\bar x$ ($x_\text{mean}$ in the figure) is the mean of the
+distribution and *Norm* is a normalization factor which is determined
+during the numerical calculation.
+
+The width is defined as
+
+.. math:: \sigma=\frac{k T}{E}
+
+which is the inverse Boltzmann factor, where $k$ is the Boltzmann constant,
+$T$ the temperature in Kelvin and $E$ a characteristic energy per particle.
+
+.. figure:: pd_boltzmann.jpg
+
+    Boltzmann distribution.
 
 .. ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ
@@ -113,36 +201,48 @@
 ^^^^^^^^^^^^^^^^^^^^^^
 
+The Lognormal Distribution describes a function of $x$ where $\ln (x)$ has
+a normal distribution. The result is a distribution that is skewed towards
+larger values of $x$.
+
 The Lognormal Distribution is defined as
 
 .. math::
 
-    f(x) = \frac{1}{\text{Norm}}
-           \frac{1}{xp}\exp\left(-\frac{(\ln(x) - \mu)^2}{2p^2}\right)
-
-where $\mu=\ln(x_\text{med})$ when $x_\text{med}$ is the median value of the
-distribution, and *Norm* is a normalization factor which will be determined
-during the numerical calculation.
-
-The median value for the distribution will be the value given for the
-respective size parameter, for example, *radius=60*.
-
-The polydispersity is given by $\sigma$
-
-.. math:: \text{PD} = p
-
-For the angular distribution
-
-.. math:: p = \sigma / x_\text{med}
-
-The mean value is given by $\bar x = \exp(\mu+ p^2/2)$. The peak value
-is given by $\max x = \exp(\mu - p^2)$.
+    f(x) = \frac{1}{\text{Norm}}\frac{1}{x\sigma}
+            \exp\left(-\frac{1}{2}
+                        \bigg(\frac{\ln(x) - \mu}{\sigma}\bigg)^2\right)
+
+where *Norm* is a normalization factor which will be determined during
+the numerical calculation, $\mu=\ln(x_\text{med})$ and $x_\text{med}$
+is the *median* value of the *lognormal* distribution, but $\sigma$ is
+a parameter describing the width of the underlying *normal* distribution.
+
+$x_\text{med}$ will be the value given for the respective size parameter
+in sasmodels, for example, *radius=60*.
+
+The polydispersity in sasmodels is given by
+
+.. math:: \text{PD} = \sigma = p / x_\text{med}
+
+The mean value of the distribution is given by $\bar x = \exp(\mu+ \sigma^2/2)$
+and the peak value by $\max x = \exp(\mu - \sigma^2)$.
+
+The variance (the square of the standard deviation) of the *lognormal*
+distribution is given by
+
+.. math::
+
+    \nu = [\exp({\sigma}^2) - 1] \exp({2\mu + \sigma^2})
+
+Note that larger values of PD might need a larger number of points
+and $N_\sigma$.
 
 .. figure:: pd_lognormal.jpg
 
-    Lognormal distribution.
-
-This distribution function spreads more, and the peak shifts to the left, as
-$p$ increases, so it requires higher values of $N_\sigma$ and more points
-in the distribution.
+    Lognormal distribution for PD=0.1.
+
+For further information on the Lognormal distribution see:
+http://en.wikipedia.org/wiki/Log-normal_distribution and
+http://mathworld.wolfram.com/LogNormalDistribution.html
 
 .. ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ
@@ -151,23 +251,31 @@
 ^^^^^^^^^^^^^^^^^^^
 
+The Schulz (sometimes written Schultz) distribution is similar to the
+Lognormal distribution, in that it is also skewed towards larger values of
+$x$, but which has computational advantages over the Lognormal distribution.
+
 The Schulz distribution is defined as
 
 .. math::
 
-    f(x) = \frac{1}{\text{Norm}}
-           (z+1)^{z+1}(x/\bar x)^z\frac{\exp[-(z+1)x/\bar x]}{\bar x\Gamma(z+1)}
-
-where $\bar x$ is the mean of the distribution and *Norm* is a normalization
-factor which is determined during the numerical calculation, and $z$ is a
-measure of the width of the distribution such that
+    f(x) = \frac{1}{\text{Norm}} (z+1)^{z+1}(x/\bar x)^z
+            \frac{\exp[-(z+1)x/\bar x]}{\bar x\Gamma(z+1)}
+
+where $\bar x$ ($x_\text{mean}$ in the figure) is the mean of the
+distribution, *Norm* is a normalization factor which is determined
+during the numerical calculation, and $z$ is a measure of the width
+of the distribution such that
 
 .. math:: z = (1-p^2) / p^2
 
-The polydispersity is
-
-.. math:: p = \sigma / \bar x
-
-Note that larger values of PD might need larger number of points and $N_\sigma$.
-For example, at PD=0.7 and radius=60 |Ang|, Npts>=160 and Nsigmas>=15 at least.
+where $p$ is the polydispersity in sasmodels given by
+
+.. math:: PD = p = \sigma / \bar x
+
+and $\sigma$ is the RMS deviation from $\bar x$.
+
+Note that larger values of PD might need a larger number of points
+and $N_\sigma$. For example, for PD=0.7 with radius=60 |Ang|, at least
+Npts>=160 and Nsigmas>=15 are required.
 
 .. figure:: pd_schulz.jpg
@@ -176,5 +284,6 @@
 
 For further information on the Schulz distribution see:
-M Kotlarchyk & S-H Chen, *J Chem Phys*, (1983), 79, 2461.
+M Kotlarchyk & S-H Chen, *J Chem Phys*, (1983), 79, 2461 and
+M Kotlarchyk, RB Stephens, and JS Huang, *J Phys Chem*, (1988), 92, 1533
 
 .. ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ
@@ -183,5 +292,5 @@
 ^^^^^^^^^^^^^^^^^^
 
-This user-definable distribution should be given as as a simple ASCII text
+This user-definable distribution should be given as a simple ASCII text
 file where the array is defined by two columns of numbers: $x$ and $f(x)$.
 The $f(x)$ will be normalized to 1 during the computation.
@@ -202,5 +311,5 @@
 given for the model will have no affect, and will be ignored when computing
 the average.  This means that any parameter with an array distribution will
-not be fittable.
+not be fitable.
 
 .. ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ
@@ -216,4 +325,11 @@
 related) except when the DLS polydispersity parameter is <0.13.
 
+.. math::
+
+    p_{DLS} = \sqrt(\nu / \bar x^2)
+
+where $\nu$ is the variance of the distribution and $\bar x$ is the mean
+value of $x$.
+
 For more information see:
 S King, C Washington & R Heenan, *Phys Chem Chem Phys*, (2005), 7, 143
@@ -225,2 +341,4 @@
 | 2015-05-01 Steve King
 | 2017-05-08 Paul Kienzle
+| 2018-03-20 Steve King
+| 2018-04-04 Steve King
Index: example/SiO2_100pc_H2O_0pc_D2O.ses
===================================================================
--- example/SiO2_100pc_H2O_0pc_D2O.ses	(revision 31c64d9fb704dafacea12163abebeea818d3f11a)
+++ example/SiO2_100pc_H2O_0pc_D2O.ses	(revision b314f89020feac2b96e4731dd784a9df4981371c)
@@ -1,36 +1,45 @@
-name	SiO2 stober						
-sample	SiO2 100pcH2O						
-collimation	unknown						
-ID	CPD						
-Date	di 27 jan 2015 18:17:50						
-Method	Offspec						
-Thickness [mm]	1						
-z-acceptance [nm^-1]	0.3						
-y-acceptance [nm^-1]	0.3						
+FileFormatVersion       1.0
+DataFileTitle           SiO2 stober
+Sample                  SiO2 100pcH2O
+collimation             unknown
+ID                      CPD
+Date                    di 27 jan 2015 18:17:50
+Method                  Offspec
+Thickness               1
+Thickness_unit          mm
+Theta_zmax              0.0100
+Theta_zmax_unit         radians
+Theta_ymax              0.0100
+Theta_ymax_unit         radians
+Orientation             Z
+SpinEchoLength_unit     A
+Depolarisation_unit     A-2 cm-1
+Wavelength_unit         A
 
-spin echo length [nm]	error spin-echo length	wavelength [nm]	error wavelength	Sample P	Sample P -error		
-244.098	5	0.220907401	0.006627222	0.96321	0.0113507	0.029882023	29.882023
-340.136	5	0.260768096	0.007823043	0.973305	0.0034846	0.085257531	85.257531
-452.181	5	0.300665994	0.00901998	0.951588	0.00287498	0.140798634	140.798634
-580.232	5	0.340587727	0.010217632	0.911935	0.00310965	0.196560502	196.560502
-724.29	5	0.380526057	0.011415782	0.856129	0.00356166	0.252528125	252.528125
-884.354	5	0.420476016	0.01261428	0.78817	0.0036651	0.308729274	308.729274
-1060.42	5	0.460433709	0.013813011	0.719608	0.00386121	0.365037117	365.037117
-1252.5	5	0.50039968	0.01501199	0.653424	0.00418035	0.421684677	421.684677
-1460.58	5	0.540369504	0.016211085	0.603456	0.00472218	0.478564852	478.564852
-1684.67	5	0.580344105	0.017410323	0.558174	0.00537231	0.535715521	535.715521
-1924.77	5	0.620322561	0.018609677	0.532672	0.00617699	0.59324408	593.24408
-2180.87	5	0.660302658	0.01980908	0.505018	0.00707792	0.650985498	650.985498
-2452.98	5	0.700285542	0.021008566	0.475536	0.00829962	0.709203958	709.203958
-2741.1	5	0.740270761	0.022208123	0.440819	0.00957178	0.767809943	767.809943
-3045.22	5	0.780256676	0.0234077	0.397642	0.0111225	0.826762925	826.762925
-3365.35	5	0.820244402	0.024607332	0.363717	0.0132055	0.886302296	886.302296
-3701.48	5	0.860232527	0.025806976	0.302206	0.0158338	0.946236366	946.236366
-4053.62	5	0.900222106	0.027006663	0.302188	0.0204351	1.006784115	1006.784115
-4421.77	5	0.940212955	0.028206389	0.263252	0.0264158	1.067891213	1067.891213
-4805.92	5	0.980203897	0.029406117	0.230149	0.0339972	1.129598722	1129.598722
-5206.08	5	1.020195903	0.030605877	0.268749	0.0480167	1.192022694	1192.022694
-5622.25	5	1.060188851	0.031805666	0.340439	0.0701945	1.255271282	1255.271282
-6054.42	5	1.10018173	0.033005452	0.238197	0.0986631	1.319307134	1319.307134
-6502.6	5	1.140175425	0.034205263	0.0985987	0.170273	1.384197821	1384.197821
-6966.79	5	1.180169852	0.035405096	0.0814573	0.256765	1.448518618	1448.518618
+BEGIN_DATA
+ SpinEchoLength  Depolarisation  Depolarisation_error   Wavelength  Wavelength_error  SpinEchoLength_error  Polarisation  Polarisation_error 
+ 2440.98   -7.6810986e-3          2.4147998e-3   2.20907401        0.06627222                    50       0.96321           0.0113507 
+ 3401.36   -3.9790857e-3          5.2649599e-4   2.60768096        0.07823043                    50      0.973305           0.0034846 
+ 4521.81   -5.4892798e-3          3.3420831e-4   3.00665994         0.0901998                    50      0.951588          0.00287498 
+ 5802.32   -7.9471175e-3          2.9396095e-4   3.40587727        0.10217632                    50      0.911935          0.00310965 
+ 7242.9    -0.010727495          2.8730584e-4   3.80526057        0.11415782                    50      0.856129          0.00356166 
+ 8843.54    -0.013463878          2.6301679e-4   4.20476016         0.1261428                    50       0.78817           0.0036651 
+ 10604.2    -0.015521222          2.5310062e-4   4.60433709        0.13813011                    50      0.719608          0.00386121 
+12525.    -0.016993983          2.5549565e-4    5.0039968         0.1501199                    50      0.653424          0.00418035 
+14605.8    -0.017297381          2.6798795e-4   5.40369504        0.16211085                    50      0.603456          0.00472218 
+16846.7    -0.017312523          2.8577242e-4   5.80344105        0.17410323                    50      0.558174          0.00537231 
+19247.7    -0.016368225          3.0135741e-4   6.20322561        0.18609677                    50      0.532672          0.00617699 
+21808.7    -0.015668849          3.2144946e-4   6.60302658         0.1980908                    50      0.505018          0.00707792 
+24529.8    -0.015157278          3.5589713e-4   7.00285542        0.21008566                    50      0.475536          0.00829962 
+27411.    -0.014947440          3.9623352e-4   7.40270761        0.22208123                    50      0.440819          0.00957178 
+30452.2    -0.015147872          4.5944674e-4   7.80256676          0.234077                    50      0.397642           0.0111225 
+33653.5    -0.015032370          5.3964070e-4   8.20244402        0.24607332                    50      0.363717           0.0132055 
+37014.8    -0.016170897          7.0802787e-4   8.60232527        0.25806976                    50      0.302206           0.0158338 
+40536.2    -0.014766858          8.3444978e-4   9.00222106        0.27006663                    50      0.302188           0.0204351 
+44217.7    -0.015097771          1.1351144e-3   9.40212955        0.28206389                    50      0.263252           0.0264158 
+48059.2    -0.015289642          1.5374507e-3   9.80203897        0.29406117                    50      0.230149           0.0339972 
+52060.8    -0.012624691          1.7166363e-3  10.20195903        0.30605877                    50      0.268749           0.0480167 
+56222.5   -9.5864674e-3          1.8344138e-3  10.60188851        0.31805666                    50      0.340439           0.0701945 
+60544.2    -0.011852755          3.4220757e-3   11.0018173        0.33005452                    50      0.238197           0.0986631 
+65026.    -0.017820748           0.013284073  11.40175425        0.34205263                    50     0.0985987            0.170273 
+69667.9    -0.018004557           0.022631679  11.80169852        0.35405096                    50     0.0814573            0.256765 
Index: example/core_shell.ses
===================================================================
--- example/core_shell.ses	(revision 5be92e8a5dcd07938111a0bdbb02d17228ee1482)
+++ example/core_shell.ses	(revision e0721e71313986771b2683caa92a663ca838b9e4)
@@ -1,68 +1,100 @@
-name	se009051.dat						
-sample	"SiO2 pH=6 & NiO pH=10, nanosized, in D2O, Sine ++ only."						
-collimation	"D1=D2=20x8 mm,Ds = 16x10 mm (WxH), GF1 =scanning, GF2 = 2.5 A. "						
-ID	CPD						
-Date	di 27 jan 2015 18:17:50						
-Method	sine one element scan						
-Thickness [mm]	2						
-z-acceptance [nm^-1]	0.3						
-y-acceptance [nm^-1]	0.3						
+FileFormatVersion       1.0
+DataFileTitle    	se009051.dat						
+Sample  		"SiO2 pH=6 & NiO pH=10, nanosized, in D2O, Sine ++ only."						
+collimation             "D1=D2=20x8 mm,Ds = 16x10 mm (WxH), GF1 =scanning, GF2 = 2.5 A. "						
+ID			CPD						
+Date			di 27 jan 2015 18:17:50
+Method			sine one element scan
+Thickness               10
+Thickness_unit          mm
+Theta_zmax              0.0168
+Theta_zmax_unit         radians
+Theta_ymax              0.0168
+Theta_ymax_unit         radians
+Orientation             Z
+SpinEchoLength_unit     A
+Depolarisation_unit     A-2 cm-1
+Wavelength_unit         A
 							
-spin echo length [um]	error spin-echo length	wavelength [nm]	error wavelength	Sample 5	Sample 5 -error		
-26	5	0.161245	0.01	0.996315	0.00528445	0.161245	26
-28.08	5	0.167571	0.01	0.995937	0.00522962	0.167571	28.08
-30.3264	5	0.174145	0.01	0.995039	0.00371164	0.174145	30.3264
-32.7525	5	0.180977	0.01	0.99448	0.00381089	0.180977	32.7525
-35.3727	5	0.188076	0.01	0.992143	0.00319011	0.188076	35.3727
-38.2025	5	0.195455	0.01	0.991403	0.0032496	0.195455	38.2025
-41.2587	5	0.203122	0.01	0.990106	0.002978	0.203122	41.2587
-44.5594	5	0.211091	0.01	0.98857	0.00297338	0.211091	44.5594
-48.1242	5	0.219372	0.01	0.986236	0.00286669	0.219372	48.1242
-51.9741	5	0.227978	0.01	0.982739	0.00283988	0.227978	51.9741
-56.132	5	0.236922	0.01	0.979995	0.00287147	0.236922	56.132
-60.6226	5	0.246217	0.01	0.976516	0.00290667	0.246217	60.6226
-65.4724	5	0.255876	0.01	0.971858	0.00300969	0.255876	65.4724
-70.7102	5	0.265914	0.01	0.967518	0.00310597	0.265914	70.7102
-76.367	5	0.276346	0.01	0.963198	0.00310409	0.276346	76.367
-82.4764	5	0.287187	0.01	0.957071	0.00306271	0.287187	82.4764
-89.0745	5	0.298454	0.01	0.952784	0.00299587	0.298454	89.0745
-96.2005	5	0.310162	0.01	0.947462	0.0029896	0.310162	96.2005
-103.897	5	0.322331	0.01	0.941226	0.00300217	0.322331	103.897
-112.208	5	0.334975	0.01	0.936151	0.00305677	0.334975	112.208
-121.185	5	0.348116	0.01	0.930884	0.00307969	0.348116	121.185
-130.88	5	0.361773	0.01	0.927992	0.00316999	0.361773	130.88
-141.35	5	0.375965	0.01	0.926511	0.00326096	0.375965	141.35
-152.658	5	0.390715	0.01	0.925098	0.00340349	0.390715	152.658
-164.871	5	0.406043	0.01	0.924369	0.0035662	0.406043	164.871
-178.06	5	0.421972	0.01	0.925632	0.00372489	0.421972	178.06
-192.305	5	0.438526	0.01	0.922762	0.00390394	0.438526	192.305
-207.69	5	0.45573	0.01	0.918753	0.00412162	0.45573	207.69
-224.305	5	0.473608	0.01	0.90663	0.0043316	0.473608	224.305
-242.249	5	0.492188	0.01	0.893251	0.00454417	0.492188	242.249
-261.629	5	0.511497	0.01	0.878299	0.00473364	0.511497	261.629
-282.559	5	0.531563	0.01	0.867266	0.00497459	0.531563	282.559
-305.164	5	0.552417	0.01	0.859537	0.00521977	0.552417	305.164
-329.577	5	0.574088	0.01	0.855935	0.0055573	0.574088	329.577
-355.943	5	0.59661	0.01	0.851389	0.00581848	0.59661	355.943
-384.419	5	0.620015	0.01	0.836311	0.00620483	0.620015	384.419
-415.172	5	0.644338	0.01	0.821362	0.00668373	0.644338	415.172
-448.386	5	0.669616	0.01	0.803833	0.0071725	0.669616	448.386
-484.257	5	0.695886	0.01	0.792211	0.00786449	0.695886	484.257
-522.998	5	0.723186	0.01	0.787407	0.00860688	0.723186	522.998
-564.838	5	0.751557	0.01	0.765977	0.00938856	0.751557	564.838
-610.025	5	0.781041	0.01	0.746618	0.0101567	0.781041	610.025
-658.827	5	0.811682	0.01	0.734672	0.0110357	0.811682	658.827
-711.533	5	0.843524	0.01	0.720351	0.0118912	0.843524	711.533
-768.455	5	0.876616	0.01	0.692587	0.0130927	0.876616	768.455
-829.932	5	0.911006	0.01	0.674767	0.0137763	0.911006	829.932
-896.326	5	0.946745	0.01	0.664497	0.0148902	0.946745	896.326
-968.032	5	0.983886	0.01	0.634464	0.0158459	0.983886	968.032
-1045.47	5	1.02248	0.01	0.604792	0.0166925	1.02248	1045.47
-1129.11	5	1.0626	0.01	0.591534	0.0179667	1.0626	1129.11
-1219.44	5	1.10428	0.01	0.582625	0.0198223	1.10428	1219.44
-1317	5	1.14761	0.01	0.549232	0.0217725	1.14761	1317
-1422.36	5	1.19263	0.01	0.53863	0.024516	1.19263	1422.36
-1536.15	5	1.23942	0.01	0.521353	0.0281108	1.23942	1536.15
-1659.04	5	1.28804	0.01	0.460045	0.0314452	1.28804	1659.04
-1791.76	5	1.33857	0.01	0.411185	0.0366899	1.33857	1791.76
-1930.34	5	1.38937	0.01	0.414685	0.0478689	1.38937	1930.34
+BEGIN_DATA
+SpinEchoLength  SpinEchoLength_error  Wavelength  Wavelength_Error  Polarisation  Polarisation_error  Depolarisation  Depolarisation_error 
+298.82023 50 2.095 0.1 0.997817556 0.00887342 -4.9779370e-5 2.0261512e-4 
+852.57531 50 2.095 0.1 1.0026184 0.009572307 5.9579929e-5 2.1752686e-4 
+1407.9863 50 2.095 0.1 0.996013172 0.012119508 -9.1017859e-5 2.7723742e-4 
+1965.6050 50 2.095 0.1 0.991752656 0.011651473 -1.8868750e-4 2.6767598e-4 
+2525.2813 50 2.095 0.1 0.995429571 0.012193294 -1.0437182e-4 2.7908883e-4 
+3087.2927 50 2.095 0.1 0.995119819 0.009621277 -1.1146275e-4 2.2028721e-4 
+3650.3712 50 2.095 0.1 0.997497767 0.012518842 -5.7082583e-5 2.8594610e-4 
+4216.8468 50 2.095 0.1 0.994733865 0.010585926 -1.2030120e-4 2.4246770e-4 
+4785.6485 50 2.095 0.1 0.992696275 0.010677724 -1.6701950e-4 2.4507231e-4 
+5357.1552 50 2.095 0.1 0.994534294 0.012909556 -1.2487278e-4 2.9574914e-4 
+5932.4408 50 2.095 0.1 0.986412268 0.014651894 -3.1170682e-4 3.3842875e-4 
+6509.8550 50 2.095 0.1 0.988788566 0.015736468 -2.5688520e-4 3.6260666e-4 
+7092.0396 50 2.095 0.1 0.989501168 0.013611615 -2.4047103e-4 3.1341898e-4 
+7678.0994 50 2.095 0.1 0.99203933 0.016501891 -1.8210252e-4 3.7899787e-4 
+8267.6293 50 2.095 0.1 0.994062723 0.010276207 -1.3567871e-4 2.3553259e-4 
+8863.0230 50 2.095 0.1 0.979647192 0.011089421 -4.6850452e-4 2.5791174e-4 
+9462.3637 50 2.095 0.1 0.97982075 0.013168527 -4.6446835e-4 3.0621222e-4 
+10067.841 50 2.095 0.1 0.977927712 0.014362819 -5.0853039e-4 3.3463000e-4 
+10678.912 50 2.095 0.1 0.978351462 0.013239476 -4.9865985e-4 3.0832436e-4 
+11295.987 50 2.095 0.1 0.981246471 0.013896898 -4.3133968e-4 3.2267975e-4 
+11920.227 50 2.095 0.1 0.978275164 0.013747481 -5.0043677e-4 3.2017989e-4 
+12552.713 50 2.095 0.1 0.976818842 0.013487944 -5.3437989e-4 3.1460359e-4 
+13193.071 50 2.095 0.1 0.981546068 0.011972943 -4.2438423e-4 2.7792152e-4 
+13841.978 50 2.095 0.1 0.96445854 0.013357572 -8.2452102e-4 3.1555561e-4 
+14500.513 50 2.095 0.1 0.972756158 0.014756189 -6.2933879e-4 3.4562263e-4 
+15169.786 50 2.095 0.1 0.971010184 0.014210504 -6.7027011e-4 3.3343996e-4 
+15850.446 50 2.095 0.1 0.975009829 0.013648839 -5.7661387e-4 3.1894710e-4 
+16543.562 50 2.095 0.1 0.969575209 0.014454336 -7.0396574e-4 3.3966327e-4 
+17249.754 50 2.095 0.1 0.959907267 0.012806944 -9.3229353e-4 3.0398222e-4 
+17969.627 50 2.095 0.1 0.96219203 0.011845496 -8.7812744e-4 2.8049391e-4 
+18706.130 50 2.095 0.1 0.960157966 0.011721155 -9.2634378e-4 2.7813758e-4 
+19459.212 50 2.095 0.1 0.95089937 0.009967287 -1.1471121e-3 2.3882201e-4 
+20230.984 50 2.095 0.1 0.955584642 0.01104368 -1.0351259e-3 2.6331561e-4 
+21021.899 50 2.095 0.1 0.950786938 0.011651582 -1.1498062e-3 2.7921171e-4 
+21835.345 50 2.095 0.1 0.951403607 0.008784149 -1.1350335e-3 2.1036178e-4 
+22671.730 50 2.095 0.1 0.94561823 0.009392383 -1.2740040e-3 2.2630383e-4 
+23533.930 50 2.095 0.1 0.945882787 0.009468264 -1.2676305e-3 2.2806833e-4 
+24423.504 50 2.095 0.1 0.939583158 0.009164239 -1.4198814e-3 2.2222511e-4 
+25343.343 50 2.095 0.1 0.93731257 0.008621568 -1.4750079e-3 2.0957224e-4 
+26294.734 50 2.095 0.1 0.936639557 0.010408821 -1.4913733e-3 2.5319842e-4 
+27282.004 50 2.095 0.1 0.932091884 0.007112674 -1.6022666e-3 1.7386258e-4 
+28307.499 50 2.095 0.1 0.933852763 0.009200431 -1.5592642e-3 2.2447176e-4 
+29373.630 50 2.095 0.1 0.930283437 0.01022719 -1.6465153e-3 2.5047996e-4 
+30485.110 50 2.095 0.1 0.930121334 0.007055747 -1.6504858e-3 1.7283645e-4 
+31645.899 50 2.095 0.1 0.925944326 0.0078917 -1.7530356e-3 1.9418588e-4 
+32858.438 50 2.095 0.1 0.92351759 0.005522582 -1.8128270e-3 1.3624763e-4 
+34128.058 50 2.095 0.1 0.918228311 0.006917328 -1.9436940e-3 1.7164045e-4 
+35460.168 50 2.095 0.1 0.908621563 0.006852313 -2.1833230e-3 1.7182490e-4 
+36858.762 50 2.095 0.1 0.919087792 0.006995961 -1.9223776e-3 1.7342924e-4 
+38331.748 50 2.095 0.1 0.910132214 0.006107336 -2.1454742e-3 1.5289007e-4 
+39882.345 50 2.095 0.1 0.904491462 0.007078101 -2.2871233e-3 1.7829708e-4 
+41519.404 50 2.095 0.1 0.90056919 0.005934428 -2.3861400e-3 1.5013907e-4 
+43249.023 50 2.095 0.1 0.903155701 0.006249069 -2.3207959e-3 1.5764661e-4 
+45079.637 50 2.095 0.1 0.894919827 0.006196232 -2.5295172e-3 1.5775222e-4 
+47019.670 50 2.095 0.1 0.896358908 0.007324999 -2.4929085e-3 1.8619052e-4 
+49077.851 50 2.095 0.1 0.88807923 0.004530465 -2.7043436e-3 1.1623128e-4 
+51264.711 50 2.095 0.1 0.886649039 0.008924412 -2.7410654e-3 2.2932944e-4 
+53590.598 50 2.095 0.1 0.882051947 0.006055081 -2.8595036e-3 1.5640756e-4 
+56067.403 50 2.095 0.1 0.882573371 0.005727419 -2.8460388e-3 1.4785638e-4 
+58709.036 50 2.095 0.1 0.876289825 0.006062306 -3.0088321e-3 1.5762389e-4 
+61527.362 50 2.095 0.1 0.877318199 0.005307783 -2.9821094e-3 1.3784403e-4 
+64538.932 50 2.095 0.1 0.873610284 0.006918104 -3.0786086e-3 1.8042690e-4 
+67758.354 50 2.095 0.1 0.865954455 0.005625863 -3.2791557e-3 1.4802192e-4 
+71205.306 50 2.095 0.1 0.868037308 0.008662141 -3.2244196e-3 2.2736248e-4 
+74897.359 50 2.095 0.1 0.86676404 0.005761209 -3.2578647e-3 1.5144143e-4 
+78855.560 50 2.095 0.1 0.85655072 0.005731411 -3.5279304e-3 1.5245456e-4 
+83102.902 50 2.095 0.1 0.857936274 0.006954572 -3.4911046e-3 1.8469168e-4 
+87663.154 50 2.095 0.1 0.85520525 0.006559622 -3.5637478e-3 1.7475934e-4 
+92562.717 50 2.095 0.1 0.849739829 0.008907377 -3.7098230e-3 2.3883381e-4 
+97830.316 50 2.095 0.1 0.841487161 0.006547659 -3.9321836e-3 1.7728439e-4 
+103497.14 50 2.095 0.1 0.84801051 0.007574866 -3.7562386e-3 2.0351933e-4 
+109596.79 50 2.095 0.1 0.843342861 0.006411993 -3.8819940e-3 1.7322909e-4 
+116165.97 50 2.095 0.1 0.837488454 0.008141353 -4.0407107e-3 2.2148775e-4 
+123244.47 50 2.095 0.1 0.839214922 0.009987535 -3.9937900e-3 2.7115465e-4 
+130874.72 50 2.095 0.1 0.834336972 0.011467406 -4.1266093e-3 3.1315233e-4 
+139102.96 50 2.095 0.1 0.828775634 0.012483365 -4.2789870e-3 3.4318369e-4 
+147980.51 50 2.095 0.1 0.823088812 0.016035848 -4.4358638e-3 4.4389186e-4 
+157560.98 50 2.095 0.1 0.824329178 0.014838138 -4.4015548e-3 4.1011975e-4 
+167904.68 50 2.095 0.1 0.82023172 0.015286187 -4.5150892e-3 4.2461424e-4 
+179076.29 50 2.095 0.1 0.827559752 0.012612741 -4.3124376e-3 3.4724985e-4 
Index: example/fit.py
===================================================================
--- example/fit.py	(revision f4b36fa94da2129ef86fb36f21c6458873815e0f)
+++ example/fit.py	(revision 1ee5ac6835494f0c31e1e5bdd12583676a07d71e)
@@ -179,5 +179,5 @@
 
 else:
-    print "No parameters for %s"%name
+    print("No parameters for %s"%name)
     sys.exit(1)
 
@@ -192,2 +192,6 @@
 else:
    problem = FitProblem(M)
+
+if __name__ == "__main__":
+   problem.plot()
+   import pylab; pylab.show()
Index: example/multiscatfit.py
===================================================================
--- example/multiscatfit.py	(revision 49d1f8b8b50df60b60d3cc8b9dfe3c9c22f24db1)
+++ example/multiscatfit.py	(revision 49d1f8b8b50df60b60d3cc8b9dfe3c9c22f24db1)
@@ -0,0 +1,90 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+Fit model using multiple scattering.
+
+As of this writing, multiscattering isn't integrated into sasmodels, and a
+number of hacks are required to get it running as a fit.
+
+The appropriate items need to be on the python path.  These include
+sasview (for reading the data), sasmodels and bumps.  The multiscat module
+(currently in the sasmodels/explore directory) is also needed, either beside
+this example fit file, or by putting sasmdoels/explore on the python path.
+
+On Unix/Mac running as developer I do::
+
+    # Show the model without fitting
+    PYTHONPATH=..:../explore:../../bumps:../../sasview/src python multiscatfit.py
+
+    # Run the fit
+    PYTHONPATH=..:../explore:../../bumps:../../sasview/src ../../bumps/run.py \
+    multiscatfit.py --store=/tmp/t1
+
+You may be able to run multiscatfit.py against the distributed sasview
+application (if it is new enough, and if you have multiscat.py in the
+same directory).  You probably need a command such as::
+
+    sasview.exe bumps.cli multiscatfit.py --store=t1
+"""
+
+import sys
+from bumps.names import *
+from sasmodels.core import load_model
+from sasmodels.bumps_model import Model, Experiment
+from sasmodels.data import load_data, set_beam_stop, set_top
+
+from sasmodels.multiscat import MultipleScattering
+
+## Load the data
+#data = load_data('DEC07267.DAT')
+#set_beam_stop(data, 0.003, outer=0.025)
+data = load_data('latex_smeared.xml', index=0)
+
+## Define the model
+kernel = load_model("ellipsoid")
+
+model = Model(
+    kernel,
+    scale=0.005, background=0.05,
+    radius_polar=2200, radius_equatorial=2200,
+    sld=.291, sld_solvent=7.105,
+    #theta=90, theta_pd=0, theta_pd_n=0, theta_pd_nsigma=3,
+    #phi=90, phi_pd=0, phi_pd_n=20, phi_pd_nsigma=3,
+    radius_polar_pd=0.222296, radius_polar_pd_n=1, radius_polar_pd_nsigma=0,
+    radius_equatorial_pd=.000128, radius_equatorial_pd_n=1, radius_equatorial_pd_nsigma=0,
+    )
+
+# SET THE FITTING PARAMETERS
+model.radius_polar.range(15, 3000)
+model.radius_equatorial.range(15, 3000)
+#model.theta.range(0, 90)
+#model.theta_pd.range(0,10)
+#model.phi_pd.range(0,20)
+#model.phi.range(0, 180)
+model.background.range(0,1000)
+model.scale.range(0, 0.1)
+
+# Mulitple scattering probability parameter
+# HACK: the probability is stuffed in as an extra parameter to the experiment.
+probability = Parameter(name="probability", value=0.0)
+probability.range(0.0, 0.9)
+
+M = Experiment(data=data, model=model, extra_pars={'probability': probability})
+
+# Stack mulitple scattering on top of the existing resolution function.
+# Because resolution functions in sasview don't have fitting parameters,
+# we instead allow the multiple scattering calculator to take a function
+# instead of a probability.  This function returns the current value of
+# the parameter. ** THIS IS TEMPORARY ** when multiple scattering is
+# properly integrated into sasmodels and sasview, its fittable parameter
+# will be treated like the model parameters.
+M.resolution = MultipleScattering(resolution=M.resolution,
+                                  probability=lambda: probability.value,
+                                  )
+M._kernel_inputs = M.resolution.q_calc
+problem = FitProblem(M)
+
+if __name__ == "__main__":
+    #M.theory()
+    M.plot()
+    import pylab; pylab.show()
Index: example/se008724_01.ses
===================================================================
--- example/se008724_01.ses	(revision 42ade62399bfd1fd54f70ab179846d303bb95d04)
+++ example/se008724_01.ses	(revision b314f89020feac2b96e4731dd784a9df4981371c)
@@ -1,131 +1,140 @@
-DataFileTitle 	Polystyrene of Markus Strobl,  Full Sine, ++ only 
-Sample 	Polystyrene 2 um in 53% H2O, 47% D2O 
-Settings 	D1=D2=20x8 mm,Ds = 16x10 mm (WxH), GF1 =scanning, GF2 = 2.5 A. 2 um polystyrene in 53% H2O, 47% D2O; 8.55% contrast 
-Operator 	CPD 
-Date 	ma 7 jul 2014 18:54:43 
-ScanType 	sine one element scan 
-Thickness [mm] 	2 
-Q_zmax [\AA^-1] 	0.05 
-Q_ymax [\AA^-1] 	0.05 
-  
-spin echo length [nm] 	 error SEL 	 wavelength [nm] 	 error wavelength 	 polarisation 	 error pol 
-49.778	2.4889	0.211	0.01055	0.99782	0.0044367
-63.041	3.152	0.211	0.01055	1.0026	0.0047862
-76.487	3.8244	0.211	0.01055	0.99601	0.0060598
-89.847	4.4924	0.211	0.01055	0.99175	0.0058257
-103.41	5.1705	0.211	0.01055	0.99543	0.0060966
-116.95	5.8475	0.211	0.01055	0.99512	0.0048106
-130.61	6.5303	0.211	0.01055	0.9975	0.0062594
-144.37	7.2184	0.211	0.01055	0.99473	0.005293
-158.2	7.9102	0.211	0.01055	0.9927	0.0053389
-172.12	8.6062	0.211	0.01055	0.99453	0.0064548
-186.17	9.3087	0.211	0.01055	0.98641	0.0073259
-200.28	10.014	0.211	0.01055	0.98879	0.0078682
-214.46	10.723	0.211	0.01055	0.9895	0.0068058
-228.73	11.436	0.211	0.01055	0.99204	0.0082509
-243.12	12.156	0.211	0.01055	0.99406	0.0051381
-257.55	12.878	0.211	0.01055	0.97965	0.0055447
-272.22	13.611	0.211	0.01055	0.97982	0.0065843
-286.9	14.345	0.211	0.01055	0.97793	0.0071814
-301.73	15.087	0.211	0.01055	0.97835	0.0066197
-316.75	15.838	0.211	0.01055	0.98125	0.0069484
-331.82	16.591	0.211	0.01055	0.97828	0.0068737
-347.16	17.358	0.211	0.01055	0.97682	0.006744
-362.45	18.122	0.211	0.01055	0.98155	0.0059865
-378.09	18.904	0.211	0.01055	0.96446	0.0066788
-393.74	19.687	0.211	0.01055	0.97276	0.0073781
-409.61	20.481	0.211	0.01055	0.97101	0.0071053
-425.55	21.278	0.211	0.01055	0.97501	0.0068244
-441.91	22.096	0.211	0.01055	0.96958	0.0072272
-458.12	22.906	0.211	0.01055	0.95991	0.0064035
-474.77	23.739	0.211	0.01055	0.96219	0.0059227
-491.52	24.576	0.211	0.01055	0.96016	0.0058606
-508.51	25.426	0.211	0.01055	0.9509	0.0049836
-525.68	26.284	0.211	0.01055	0.95558	0.0055218
-543.08	27.154	0.211	0.01055	0.95079	0.0058258
-560.74	28.037	0.211	0.01055	0.9514	0.0043921
-578.69	28.935	0.211	0.01055	0.94562	0.0046962
-596.75	29.838	0.211	0.01055	0.94588	0.0047341
-615.17	30.758	0.211	0.01055	0.93958	0.0045821
-633.77	31.688	0.211	0.01055	0.93731	0.0043108
-652.82	32.641	0.211	0.01055	0.93664	0.0052044
-672.04	33.602	0.211	0.01055	0.93209	0.0035563
-691.57	34.578	0.211	0.01055	0.93385	0.0046002
-711.48	35.574	0.211	0.01055	0.93028	0.0051136
-731.67	36.583	0.211	0.01055	0.93012	0.0035279
-752.17	37.608	0.211	0.01055	0.92594	0.0039458
-773.1	38.655	0.211	0.01055	0.92352	0.0027613
-794.42	39.721	0.211	0.01055	0.91823	0.0034587
-816.14	40.807	0.211	0.01055	0.90862	0.0034262
-838.19	41.91	0.211	0.01055	0.91909	0.003498
-860.69	43.034	0.211	0.01055	0.91013	0.0030537
-883.74	44.187	0.211	0.01055	0.90449	0.0035391
-907.04	45.352	0.211	0.01055	0.90057	0.0029672
-930.95	46.547	0.211	0.01055	0.90316	0.0031245
-955.38	47.769	0.211	0.01055	0.89492	0.0030981
-980.25	49.013	0.211	0.01055	0.89636	0.0036625
-1005.7	50.284	0.211	0.01055	0.88808	0.0022652
-1031.7	51.586	0.211	0.01055	0.88665	0.0044622
-1058.3	52.914	0.211	0.01055	0.88205	0.0030275
-1085.5	54.276	0.211	0.01055	0.88257	0.0028637
-1113.4	55.669	0.211	0.01055	0.87629	0.0030312
-1141.9	57.093	0.211	0.01055	0.87732	0.0026539
-1171	58.55	0.211	0.01055	0.87361	0.0034591
-1201	60.052	0.211	0.01055	0.86595	0.0028129
-1231.6	61.582	0.211	0.01055	0.86804	0.0043311
-1263	63.151	0.211	0.01055	0.86676	0.0028806
-1295.2	64.762	0.211	0.01055	0.85655	0.0028657
-1328.4	66.419	0.211	0.01055	0.85794	0.0034773
-1362.3	68.113	0.211	0.01055	0.85521	0.0032798
-1397.1	69.854	0.211	0.01055	0.84974	0.0044537
-1432.9	71.644	0.211	0.01055	0.84149	0.0032738
-1469.7	73.487	0.211	0.01055	0.84801	0.0037874
-1507.5	75.376	0.211	0.01055	0.84334	0.003206
-1546.4	77.318	0.211	0.01055	0.83749	0.0040707
-1586.4	79.322	0.211	0.01055	0.83921	0.0049938
-1627.6	81.381	0.211	0.01055	0.83434	0.0057337
-1669.9	83.497	0.211	0.01055	0.82878	0.0062417
-1713.6	85.678	0.211	0.01055	0.82309	0.0080179
-1758.5	87.927	0.211	0.01055	0.82433	0.0074191
-1804.8	90.239	0.211	0.01055	0.82023	0.0076431
-1852.5	92.627	0.211	0.01055	0.82756	0.0063064
-1901.7	95.087	0.211	0.01055	0.82584	0.0052944
-1952.4	97.622	0.211	0.01055	0.82799	0.0050662
-2004.8	100.24	0.211	0.01055	0.82345	0.0043127
-2058.8	102.94	0.211	0.01055	0.82296	0.0038
-2114.5	105.72	0.211	0.01055	0.81987	0.0034072
-2172	108.6	0.211	0.01055	0.82021	0.0036752
-2231.4	111.57	0.211	0.01055	0.82765	0.0028851
-2292.8	114.64	0.211	0.01055	0.82664	0.0038942
-2356.2	117.81	0.211	0.01055	0.82702	0.0047371
-2421.8	121.09	0.211	0.01055	0.81593	0.0043772
-2489.6	124.48	0.211	0.01055	0.8251	0.0028026
-2559.5	127.98	0.211	0.01055	0.8292	0.0024574
-2631.9	131.59	0.211	0.01055	0.82626	0.0036198
-2706.8	135.34	0.211	0.01055	0.8208	0.0032314
-2784.3	139.22	0.211	0.01055	0.81959	0.0042731
-2864.5	143.23	0.211	0.01055	0.82653	0.002699
-2947.5	147.38	0.211	0.01055	0.82401	0.0036726
-3033.5	151.67	0.211	0.01055	0.82361	0.0048224
-3122.4	156.12	0.211	0.01055	0.82358	0.0041221
-3214.5	160.73	0.211	0.01055	0.82187	0.0028807
-3310.1	165.5	0.211	0.01055	0.82644	0.003516
-3409	170.45	0.211	0.01055	0.82355	0.0021166
-3511.4	175.57	0.211	0.01055	0.82513	0.0033911
-3617.6	180.88	0.211	0.01055	0.82802	0.0015342
-3727.6	186.38	0.211	0.01055	0.82663	0.0029222
-3841.7	192.08	0.211	0.01055	0.82026	0.0020755
-3960	198	0.211	0.01055	0.83079	0.0026394
-4082.7	204.13	0.211	0.01055	0.82665	0.0027466
-4209.9	210.5	0.211	0.01055	0.82774	0.0025199
-4341.9	217.09	0.211	0.01055	0.83489	0.0030619
-4478.7	223.94	0.211	0.01055	0.81987	0.0020988
-4620.8	231.04	0.211	0.01055	0.8253	0.0023899
-4768.2	238.41	0.211	0.01055	0.82653	0.0022851
-4921.1	246.06	0.211	0.01055	0.82442	0.003383
-5079.8	253.99	0.211	0.01055	0.82827	0.0015979
-5244.7	262.23	0.211	0.01055	0.82494	0.0031129
-5415.7	270.79	0.211	0.01055	0.82183	0.0030149
-5593.3	279.67	0.211	0.01055	0.83217	0.0046976
-5777.8	288.89	0.211	0.01055	0.82227	0.005574
-5969.3	298.46	0.211	0.01055	0.82338	0.0025569
+FileFormatVersion       1.0
+DataFileTitle           Polystyrene of Markus Strobl,  Full Sine, ++ only
+Sample                  Polystyrene 2 um in 53% H2O, 47% D2O
+Settings                D1=D2=20x8 mm,Ds = 16x10 mm (WxH), GF1 =scanning, GF2 = 2.5 A. 2 um polystyrene in 53% H2O, 47% D2O; 8.55% contrast
+Operator                CPD
+Date                    ma 7 jul 2014 18:54:43
+ScanType                sine one element scan
+Thickness               2
+Thickness_unit          mm
+Theta_zmax              0.0168
+Theta_zmax_unit         radians
+Theta_ymax              0.0168
+Theta_ymax_unit         radians
+Orientation             Z
+SpinEchoLength_unit     A
+Depolarisation_unit     A-2 cm-1
+Wavelength_unit         A
+
+BEGIN_DATA
+SpinEchoLength Depolarisation Depolarisation_error Wavelength Wavelength_error SpinEchoLength_error Polarisation  Polarisation_error
+497.78  -2.4509553e-4  4.9935908e-4  2.11  0.1055  24.889  0.99782  0.0044367 
+630.41   2.9161810e-4  5.3612769e-4  2.11  0.1055   31.52   1.0026  0.0047862 
+764.87  -4.4899949e-4  6.8328154e-4  2.11  0.1055  38.244  0.99601  0.0060598 
+898.47  -9.3037214e-4  6.5970686e-4  2.11  0.1055  44.924  0.99175  0.0058257 
+1034.1  -5.1441728e-4  6.8783151e-4  2.11  0.1055  51.705  0.99543  0.0060966 
+1169.5  -5.4939760e-4  5.4291131e-4  2.11  0.1055  58.475  0.99512  0.0048106 
+1306.1  -2.8111792e-4  7.0473347e-4  2.11  0.1055  65.303   0.9975  0.0062594 
+1443.7  -5.9342057e-4  5.9758787e-4  2.11  0.1055  72.184  0.99473   0.005293 
+1582.  -8.2284488e-4  6.0400267e-4  2.11  0.1055  79.102   0.9927  0.0053389 
+1721.2  -6.1600315e-4  7.2890343e-4  2.11  0.1055  86.062  0.99453  0.0064548 
+1861.7  -1.5367118e-3  8.3408174e-4  2.11  0.1055  93.087  0.98641  0.0073259 
+2002.8  -1.2660661e-3  8.9366844e-4  2.11  0.1055  100.14  0.98879  0.0078682 
+2144.6  -1.1854534e-3  7.7244662e-4  2.11  0.1055  107.23   0.9895  0.0068058 
+2287.3  -8.9753711e-4  9.3406529e-4  2.11  0.1055  114.36  0.99204  0.0082509 
+2431.2  -6.6909009e-4  5.8049041e-4  2.11  0.1055  121.56  0.99406  0.0051381 
+2575.5  -2.3090130e-3  6.3564144e-4  2.11  0.1055  128.78  0.97965  0.0055447 
+2722.2  -2.2895260e-3  7.5468967e-4  2.11  0.1055  136.11  0.97982  0.0065843 
+2869.  -2.5063662e-3  8.2471984e-4  2.11  0.1055  143.45  0.97793  0.0071814 
+3017.3  -2.4581433e-3  7.5988724e-4  2.11  0.1055  150.87  0.97835  0.0066197 
+3167.5  -2.1257395e-3  7.9526201e-4  2.11  0.1055  158.38  0.98125  0.0069484 
+3318.2  -2.4661790e-3  7.8910082e-4  2.11  0.1055  165.91  0.97828  0.0068737 
+3471.6  -2.6339122e-3  7.7536843e-4  2.11  0.1055  173.58  0.97682   0.006744 
+3624.5  -2.0914090e-3  6.8496070e-4  2.11  0.1055  181.22  0.98155  0.0059865 
+3780.9  -4.0640282e-3  7.7771292e-4  2.11  0.1055  189.04  0.96446  0.0066788 
+3937.4  -3.1016697e-3  8.5181234e-4  2.11  0.1055  196.87  0.97276  0.0073781 
+4096.1  -3.3038917e-3  8.2179560e-4  2.11  0.1055  204.81  0.97101  0.0071053 
+4255.5  -2.8422039e-3  7.8606869e-4  2.11  0.1055  212.78  0.97501  0.0068244 
+4419.1  -3.4694067e-3  8.3712733e-4  2.11  0.1055  220.96  0.96958  0.0072272 
+4581.2  -4.5951067e-3  7.4919003e-4  2.11  0.1055  229.06  0.95991  0.0064035 
+4747.7  -4.3286699e-3  6.9129591e-4  2.11  0.1055  237.39  0.96219  0.0059227 
+4915.2  -4.5658612e-3  6.8549385e-4  2.11  0.1055  245.76  0.96016  0.0058606 
+5085.1  -5.6542277e-3  5.8859074e-4  2.11  0.1055  254.26   0.9509  0.0049836 
+5256.8  -5.1028496e-3  6.4896117e-4  2.11  0.1055  262.84  0.95558  0.0055218 
+5430.8  -5.6672201e-3  6.8813882e-4  2.11  0.1055  271.54  0.95079  0.0058258 
+5607.4  -5.5951905e-3  5.1845870e-4  2.11  0.1055  280.37   0.9514  0.0043921 
+5786.9  -6.2795627e-3  5.5774416e-4  2.11  0.1055  289.35  0.94562  0.0046962 
+5967.5  -6.2486880e-3  5.6209080e-4  2.11  0.1055  298.38  0.94588  0.0047341 
+6151.7  -6.9992040e-3  5.4769136e-4  2.11  0.1055  307.58  0.93958  0.0045821 
+6337.7  -7.2708619e-3  5.1651117e-4  2.11  0.1055  316.88  0.93731  0.0043108 
+6528.2  -7.3511686e-3  6.2402654e-4  2.11  0.1055  326.41  0.93664  0.0052044 
+6720.4  -7.8980596e-3  4.2849488e-4  2.11  0.1055  336.02  0.93209  0.0035563 
+6915.7  -7.6861990e-3  5.5322868e-4  2.11  0.1055  345.78  0.93385  0.0046002 
+7114.8  -8.1163566e-3  6.1733111e-4  2.11  0.1055  355.74  0.93028  0.0051136 
+7316.7  -8.1356741e-3  4.2597330e-4  2.11  0.1055  365.83  0.93012  0.0035279 
+7521.7  -8.6415221e-3  4.7858305e-4  2.11  0.1055  376.08  0.92594  0.0039458 
+7731.  -8.9354263e-3  3.3579357e-4  2.11  0.1055  386.55  0.92352  0.0027613 
+7944.2  -9.5805772e-3  4.2302546e-4  2.11  0.1055  397.21  0.91823  0.0034587 
+8161.4   -0.010762148  4.2348254e-4  2.11  0.1055  408.07  0.90862  0.0034262 
+8381.9  -9.4754418e-3  4.2743183e-4  2.11  0.1055   419.1  0.91909   0.003498 
+8606.9   -0.010575665  3.7681487e-4  2.11  0.1055  430.34  0.91013  0.0030537 
+8837.4   -0.011273784  4.3943451e-4  2.11  0.1055  441.87  0.90449  0.0035391 
+9070.4   -0.011761571  3.7002787e-4  2.11  0.1055  453.52  0.90057  0.0029672 
+9309.5   -0.011439046  3.8852675e-4  2.11  0.1055  465.47  0.90316  0.0031245 
+9553.8   -0.012468380  3.8879110e-4  2.11  0.1055  477.69  0.89492  0.0030981 
+9802.5   -0.012287815  4.5888119e-4  2.11  0.1055  490.13  0.89636  0.0036625 
+10057.   -0.013330052  2.8645708e-4  2.11  0.1055  502.84  0.88808  0.0022652 
+10317.   -0.013511036  5.6519968e-4  2.11  0.1055  515.86  0.88665  0.0044622 
+10583.   -0.014095206  3.8547484e-4  2.11  0.1055  529.14  0.88205  0.0030275 
+10855.   -0.014029017  3.6440427e-4  2.11  0.1055  542.76  0.88257  0.0028637 
+11134.   -0.014831000  3.8848283e-4  2.11  0.1055  556.69  0.87629  0.0030312 
+11419.   -0.014699072  3.3972822e-4  2.11  0.1055  570.93  0.87732  0.0026539 
+11710   -0.015174999  4.4468309e-4  2.11  0.1055   585.5  0.87361  0.0034591 
+12010   -0.016164070  3.6480986e-4  2.11  0.1055  600.52  0.86595  0.0028129 
+12316   -0.015893340  5.6035541e-4  2.11  0.1055  615.82  0.86804  0.0043311 
+12630   -0.016059068  3.7324087e-4  2.11  0.1055  631.51  0.86676  0.0028806 
+12952.   -0.017389837  3.7573625e-4  2.11  0.1055  647.62  0.85655  0.0028657 
+13284.   -0.017207735  4.5518751e-4  2.11  0.1055  664.19  0.85794  0.0034773 
+13623.   -0.017565669  4.3070477e-4  2.11  0.1055  681.13  0.85521  0.0032798 
+13971.   -0.018286298  5.8862675e-4  2.11  0.1055  698.54  0.84974  0.0044537 
+14329.   -0.019381994  4.3692639e-4  2.11  0.1055  716.44  0.84149  0.0032738 
+14697.   -0.018515178  5.0158587e-4  2.11  0.1055  734.87  0.84801  0.0037874 
+15075.   -0.019135361  4.2693908e-4  2.11  0.1055  753.76  0.84334   0.003206 
+15464.   -0.019917113  5.4587670e-4  2.11  0.1055  773.18  0.83749  0.0040707 
+15864.   -0.019686699  6.6829096e-4  2.11  0.1055  793.22  0.83921  0.0049938 
+16276.   -0.020340321  7.7178617e-4  2.11  0.1055  813.81  0.83434  0.0057337 
+16699.   -0.021091231  8.4580203e-4  2.11  0.1055  834.97  0.82878  0.0062417 
+17136.   -0.021864932  1.0940027e-3  2.11  0.1055  856.78  0.82309  0.0080179 
+17585.   -0.021695868  1.0107767e-3  2.11  0.1055  879.27  0.82433  0.0074191 
+18048.   -0.022255844  1.0464994e-3  2.11  0.1055  902.39  0.82023  0.0076431 
+18525.   -0.021256673  8.5582923e-4  2.11  0.1055  926.27  0.82756  0.0063064 
+19017.   -0.021490334  7.1998911e-4  2.11  0.1055  950.87  0.82584  0.0052944 
+19524.   -0.021198334  6.8716706e-4  2.11  0.1055  976.22  0.82799  0.0050662 
+20048.   -0.021815823  5.8818928e-4  2.11  0.1055  1002.4  0.82345  0.0043127 
+20588.   -0.021882671  5.1857307e-4  2.11  0.1055  1029.4  0.82296     0.0038 
+21145.   -0.022305147  4.6672141e-4  2.11  0.1055  1057.2  0.81987  0.0034072 
+21720   -0.022258583  5.0322361e-4  2.11  0.1055   1086.  0.82021  0.0036752 
+22314.   -0.021244460  3.9148871e-4  2.11  0.1055  1115.7  0.82765  0.0028851 
+22928.   -0.021381594  5.2906244e-4  2.11  0.1055  1146.4  0.82664  0.0038942 
+23562.   -0.021329979  6.4328235e-4  2.11  0.1055  1178.1  0.82702  0.0047371 
+24218.   -0.022846153  6.0248825e-4  2.11  0.1055  1210.9  0.81593  0.0043772 
+24896.   -0.021591012  3.8146933e-4  2.11  0.1055  1244.8   0.8251  0.0028026 
+25595.   -0.021034332  3.3282938e-4  2.11  0.1055  1279.8   0.8292  0.0024574 
+26319.   -0.021433232  4.9200888e-4  2.11  0.1055  1315.9  0.82626  0.0036198 
+27068.   -0.022177827  4.4213864e-4  2.11  0.1055  1353.4   0.8208  0.0032314 
+27843.   -0.022343508  5.8553317e-4  2.11  0.1055  1392.2  0.81959  0.0042731 
+28645.   -0.021396539  3.6673246e-4  2.11  0.1055  1432.3  0.82653   0.002699 
+29475.   -0.021739473  5.0054859e-4  2.11  0.1055  1473.8  0.82401  0.0036726 
+30335.   -0.021794003  6.5757715e-4  2.11  0.1055  1516.7  0.82361  0.0048224 
+31224.   -0.021798094  5.6210549e-4  2.11  0.1055  1561.2  0.82358  0.0041221 
+32145.   -0.022031519  3.9364070e-4  2.11  0.1055  1607.3  0.82187  0.0028807 
+33101.   -0.021408769  4.7779613e-4  2.11  0.1055   1655.  0.82644   0.003516 
+34090   -0.021802185  2.8863827e-4  2.11  0.1055  1704.5  0.82355  0.0021166 
+35114.   -0.021586929  4.6155484e-4  2.11  0.1055  1755.7  0.82513  0.0033911 
+36176.   -0.021194265  2.0808762e-4  2.11  0.1055  1808.8  0.82802  0.0015342 
+37276.   -0.021382952  3.9701221e-4  2.11  0.1055  1863.8  0.82663  0.0029222 
+38417.   -0.022251737  2.8416874e-4  2.11  0.1055  1920.8  0.82026  0.0020755 
+39600   -0.020819189  3.5679523e-4  2.11  0.1055  1980.0  0.83079  0.0026394 
+40827.   -0.021380235  3.7314604e-4  2.11  0.1055  2041.3  0.82665  0.0027466 
+42099.   -0.021232248  3.4189634e-4  2.11  0.1055   2105.  0.82774  0.0025199 
+43419.   -0.020266312  4.1187633e-4  2.11  0.1055  2170.9  0.83489  0.0030619 
+44787.   -0.022305147  2.8749557e-4  2.11  0.1055  2239.4  0.81987  0.0020988 
+46208.   -0.021563793  3.2521680e-4  2.11  0.1055  2310.4   0.8253  0.0023899 
+47682.   -0.021396539  3.1049291e-4  2.11  0.1055  2384.1  0.82653  0.0022851 
+49211.   -0.021683607  4.6084892e-4  2.11  0.1055  2460.6  0.82442   0.003383 
+50798.   -0.021160361  2.1666201e-4  2.11  0.1055  2539.9  0.82827  0.0015979 
+52447.   -0.021612792  4.2378726e-4  2.11  0.1055  2622.3  0.82494  0.0031129 
+54157.   -0.022036985  4.1199886e-4  2.11  0.1055  2707.9  0.82183  0.0030149 
+55933.   -0.020632795  6.3397053e-4  2.11  0.1055  2796.7  0.83217  0.0046976 
+57778.   -0.021976873  7.6130313e-4  2.11  0.1055  2888.9  0.82227   0.005574 
+59693.   -0.021825370  3.4875346e-4  2.11  0.1055  2984.6  0.82338  0.0025569 
Index: example/se008731_01_40pcorr.ses
===================================================================
--- example/se008731_01_40pcorr.ses	(revision 2f8ee5f914873d15682adee6093e19798c1b0d1c)
+++ example/se008731_01_40pcorr.ses	(revision b314f89020feac2b96e4731dd784a9df4981371c)
@@ -1,51 +1,60 @@
-DataFileTitle 	"Polystyrene of Markus Strobl,  Full Sine, ++ only "						
-Sample 	"Polystyrene 2 um in 53% H2O, 47% D2O "						
-Settings 	"D1=D2=20x8 mm,Ds = 16x10 mm (WxH), GF1 =scanning, GF2 = 2.5 A. 2 um polystyrene in 53% H2O, 47% D2O; 8.55% contrast "						
-Operator 	CPD 						
-Date 	do 10 jul 2014 16:37:30 						
-ScanType 	sine one element scan 						
-Thickness [cm] 	2.00E-01						
-Q_zmax [\A^-1] 	0.05						
-Q_ymax [\A^-1] 	0.05						
-  							
-spin echo length [A] 	 Depolarisation [A-2 cm-1] 	 error depol [A-2 cm-1] 	 error SEL [A] 	 wavelength [A] 	 error wavelength [A] 	 polarisation 	 error pol 
-391.56	0.0041929	0.0036894	19.578	2.11	0.1055	1.0037	0.0032974
-1564	-0.0046571	0.0038185	78.2	2.11	0.1055	0.99586	0.003386
-2735.6	-0.017007	0.0038132	136.78	2.11	0.1055	0.98497	0.0033444
-3907.9	-0.033462	0.0035068	195.39	2.11	0.1055	0.97064	0.0030309
-5080.2	-0.047483	0.0038208	254.01	2.11	0.1055	0.9586	0.0032613
-6251.8	-0.070375	0.00376	312.59	2.11	0.1055	0.93926	0.0031446
-7423.2	-0.092217	0.0037927	371.16	2.11	0.1055	0.92117	0.0031108
-8595.5	-0.10238	0.004006	429.77	2.11	0.1055	0.91287	0.0032562
-9767.7	-0.12672	0.0038534	488.39	2.11	0.1055	0.8933	0.0030651
-10940	-0.1374	0.004243	546.98	2.11	0.1055	0.88484	0.003343
-12112	-0.16072	0.0045837	605.58	2.11	0.1055	0.86666	0.0035372
-13284	-0.16623	0.0045613	664.2	2.11	0.1055	0.86242	0.0035027
-14456	-0.18468	0.0044918	722.79	2.11	0.1055	0.84837	0.0033931
-15628	-0.19143	0.0048967	781.38	2.11	0.1055	0.84328	0.0036768
-16800	-0.20029	0.0045421	840.02	2.11	0.1055	0.83666	0.0033837
-17971	-0.19798	0.0046642	898.56	2.11	0.1055	0.83838	0.0034819
-19143	-0.21442	0.0047052	957.17	2.11	0.1055	0.82619	0.0034614
-20316	-0.20885	0.0044931	1015.8	2.11	0.1055	0.8303	0.0033218
-21488	-0.21393	0.0049186	1074.4	2.11	0.1055	0.82655	0.00362
-22660	-0.20685	0.004423	1133	2.11	0.1055	0.83179	0.0032758
-23832	-0.20802	0.0046979	1191.6	2.11	0.1055	0.83092	0.0034758
-25003	-0.19848	0.0045953	1250.2	2.11	0.1055	0.838	0.0034289
-26175	-0.21117	0.0044567	1308.8	2.11	0.1055	0.82859	0.0032881
-27347	-0.21283	0.004137	1367.4	2.11	0.1055	0.82736	0.0030477
-28520	-0.2042	0.0044587	1426	2.11	0.1055	0.83375	0.0033101
-29692	-0.2112	0.0042852	1484.6	2.11	0.1055	0.82857	0.0031615
-30864	-0.20319	0.0043483	1543.2	2.11	0.1055	0.8345	0.003231
-32036	-0.20752	0.0044297	1601.8	2.11	0.1055	0.83129	0.0032788
-33207	-0.20654	0.0043188	1660.4	2.11	0.1055	0.83201	0.0031995
-34380	-0.20126	0.0046375	1719	2.11	0.1055	0.83593	0.0034518
-35551	-0.20924	0.0042871	1777.6	2.11	0.1055	0.83001	0.0031684
-36724	-0.21323	0.0045471	1836.2	2.11	0.1055	0.82707	0.0033487
-37895	-0.21324	0.0045354	1894.7	2.11	0.1055	0.82706	0.00334
-39067	-0.19905	0.0044141	1953.4	2.11	0.1055	0.83758	0.003292
-40239	-0.1991	0.0047441	2012	2.11	0.1055	0.83754	0.003538
-41411	-0.20359	0.0050136	2070.5	2.11	0.1055	0.8342	0.003724
-42583	-0.21032	0.0049474	2129.1	2.11	0.1055	0.82922	0.0036529
-43755	-0.20689	0.0048203	2187.8	2.11	0.1055	0.83176	0.00357
-44927	-0.21075	0.0052337	2246.4	2.11	0.1055	0.8289	0.0038628
-46099	-0.19956	0.0047827	2304.9	2.11	0.1055	0.8372	0.0035653
+FileFormatVersion       1.0
+DataFileTitle           Polystyrene of Markus Strobl,  Full Sine, ++ only
+Sample                  Polystyrene 2 um in 53% H2O, 47% D2O
+Settings                "D1=D2=20x8 mm,Ds = 16x10 mm (WxH), GF1 =scanning, GF2 = 2.5 A. 2 um polystyrene in 53% H2O, 47% D2O; 8.55% contrast "						
+Operator                CPD
+Date                    do 10 jul 2014 16:37:30
+ScanType                sine one element scan
+Thickness               2
+Thickness_unit          mm
+Theta_zmax              0.0168
+Theta_zmax_unit         radians
+Theta_ymax              0.0168
+Theta_ymax_unit         radians
+Orientation             Z
+SpinEchoLength_unit     A
+Depolarisation_unit     A-2 cm-1
+Wavelength_unit         A
+
+BEGIN_DATA
+SpinEchoLength Depolarisation Depolarisation_error Polarisation Polarisation_error SpinEchoLength_error Wavelength Wavelength_error 
+391.56 0.0041929 0.0036894 1.0037 0.0032974 19.578 2.11 0.1055 
+1564 -0.0046571 0.0038185 0.99586 0.003386 78.2 2.11 0.1055 
+2735.6 -0.017007 0.0038132 0.98497 0.0033444 136.78 2.11 0.1055 
+3907.9 -0.033462 0.0035068 0.97064 0.0030309 195.39 2.11 0.1055 
+5080.2 -0.047483 0.0038208 0.9586 0.0032613 254.01 2.11 0.1055 
+6251.8 -0.070375 0.00376 0.93926 0.0031446 312.59 2.11 0.1055 
+7423.2 -0.092217 0.0037927 0.92117 0.0031108 371.16 2.11 0.1055 
+8595.5 -0.10238 0.004006 0.91287 0.0032562 429.77 2.11 0.1055 
+9767.7 -0.12672 0.0038534 0.8933 0.0030651 488.39 2.11 0.1055 
+10940 -0.1374 0.004243 0.88484 0.003343 546.98 2.11 0.1055 
+12112 -0.16072 0.0045837 0.86666 0.0035372 605.58 2.11 0.1055 
+13284 -0.16623 0.0045613 0.86242 0.0035027 664.2 2.11 0.1055 
+14456 -0.18468 0.0044918 0.84837 0.0033931 722.79 2.11 0.1055 
+15628 -0.19143 0.0048967 0.84328 0.0036768 781.38 2.11 0.1055 
+16800 -0.20029 0.0045421 0.83666 0.0033837 840.02 2.11 0.1055 
+17971 -0.19798 0.0046642 0.83838 0.0034819 898.56 2.11 0.1055 
+19143 -0.21442 0.0047052 0.82619 0.0034614 957.17 2.11 0.1055 
+20316 -0.20885 0.0044931 0.8303 0.0033218 1015.8 2.11 0.1055 
+21488 -0.21393 0.0049186 0.82655 0.00362 1074.4 2.11 0.1055 
+22660 -0.20685 0.004423 0.83179 0.0032758 1133 2.11 0.1055 
+23832 -0.20802 0.0046979 0.83092 0.0034758 1191.6 2.11 0.1055 
+25003 -0.19848 0.0045953 0.838 0.0034289 1250.2 2.11 0.1055 
+26175 -0.21117 0.0044567 0.82859 0.0032881 1308.8 2.11 0.1055 
+27347 -0.21283 0.004137 0.82736 0.0030477 1367.4 2.11 0.1055 
+28520 -0.2042 0.0044587 0.83375 0.0033101 1426 2.11 0.1055 
+29692 -0.2112 0.0042852 0.82857 0.0031615 1484.6 2.11 0.1055 
+30864 -0.20319 0.0043483 0.8345 0.003231 1543.2 2.11 0.1055 
+32036 -0.20752 0.0044297 0.83129 0.0032788 1601.8 2.11 0.1055 
+33207 -0.20654 0.0043188 0.83201 0.0031995 1660.4 2.11 0.1055 
+34380 -0.20126 0.0046375 0.83593 0.0034518 1719 2.11 0.1055 
+35551 -0.20924 0.0042871 0.83001 0.0031684 1777.6 2.11 0.1055 
+36724 -0.21323 0.0045471 0.82707 0.0033487 1836.2 2.11 0.1055 
+37895 -0.21324 0.0045354 0.82706 0.00334 1894.7 2.11 0.1055 
+39067 -0.19905 0.0044141 0.83758 0.003292 1953.4 2.11 0.1055 
+40239 -0.1991 0.0047441 0.83754 0.003538 2012 2.11 0.1055 
+41411 -0.20359 0.0050136 0.8342 0.003724 2070.5 2.11 0.1055 
+42583 -0.21032 0.0049474 0.82922 0.0036529 2129.1 2.11 0.1055 
+43755 -0.20689 0.0048203 0.83176 0.00357 2187.8 2.11 0.1055 
+44927 -0.21075 0.0052337 0.8289 0.0038628 2246.4 2.11 0.1055 
+46099 -0.19956 0.0047827 0.8372 0.0035653 2304.9 2.11 0.1055 
Index: example/sphere.ses
===================================================================
--- example/sphere.ses	(revision a98958b137d45599ef7358a57d334a32fb6bc06c)
+++ example/sphere.ses	(revision e0721e71313986771b2683caa92a663ca838b9e4)
@@ -1,91 +1,100 @@
-name	se009051.dat						
-sample	"SiO2 pH=6 & NiO pH=10, nanosized, in D2O, Sine ++ only."						
-collimation	"D1=D2=20x8 mm,Ds = 16x10 mm (WxH), GF1 =scanning, GF2 = 2.5 A. "						
-ID	CPD						
-Date	di 27 jan 2015 18:17:50						
-Method	sine one element scan						
-Thickness [mm]	10						
-z-acceptance [nm^-1]	0.3						
-y-acceptance [nm^-1]	0.3						
+FileFormatVersion       1.0
+DataFileTitle    	se009051.dat						
+Sample  		"SiO2 pH=6 & NiO pH=10, nanosized, in D2O, Sine ++ only."						
+collimation             "D1=D2=20x8 mm,Ds = 16x10 mm (WxH), GF1 =scanning, GF2 = 2.5 A. "						
+ID			CPD						
+Date			di 27 jan 2015 18:17:50
+Method			sine one element scan
+Thickness               10
+Thickness_unit          mm
+Theta_zmax              0.0168
+Theta_zmax_unit         radians
+Theta_ymax              0.0168
+Theta_ymax_unit         radians
+Orientation             Z
+SpinEchoLength_unit     A
+Depolarisation_unit     A-2 cm-1
+Wavelength_unit         A
 							
-spin echo length [um]	error spin-echo length	wavelength [nm]	error wavelength	Sample 5	Sample 5 -error		
-29.882023	5	0.2095	0.01	0.997817556	0.00887342	0.029882023	29.882023
-85.257531	5	0.2095	0.01	1.0026184	0.009572307	0.085257531	85.257531
-140.798634	5	0.2095	0.01	0.996013172	0.012119508	0.140798634	140.798634
-196.560502	5	0.2095	0.01	0.991752656	0.011651473	0.196560502	196.560502
-252.528125	5	0.2095	0.01	0.995429571	0.012193294	0.252528125	252.528125
-308.729274	5	0.2095	0.01	0.995119819	0.009621277	0.308729274	308.729274
-365.037117	5	0.2095	0.01	0.997497767	0.012518842	0.365037117	365.037117
-421.684677	5	0.2095	0.01	0.994733865	0.010585926	0.421684677	421.684677
-478.564852	5	0.2095	0.01	0.992696275	0.010677724	0.478564852	478.564852
-535.715521	5	0.2095	0.01	0.994534294	0.012909556	0.535715521	535.715521
-593.24408	5	0.2095	0.01	0.986412268	0.014651894	0.59324408	593.24408
-650.985498	5	0.2095	0.01	0.988788566	0.015736468	0.650985498	650.985498
-709.203958	5	0.2095	0.01	0.989501168	0.013611615	0.709203958	709.203958
-767.809943	5	0.2095	0.01	0.99203933	0.016501891	0.767809943	767.809943
-826.762925	5	0.2095	0.01	0.994062723	0.010276207	0.826762925	826.762925
-886.302296	5	0.2095	0.01	0.979647192	0.011089421	0.886302296	886.302296
-946.236366	5	0.2095	0.01	0.97982075	0.013168527	0.946236366	946.236366
-1006.784115	5	0.2095	0.01	0.977927712	0.014362819	1.006784115	1006.784115
-1067.891213	5	0.2095	0.01	0.978351462	0.013239476	1.067891213	1067.891213
-1129.598722	5	0.2095	0.01	0.981246471	0.013896898	1.129598722	1129.598722
-1192.022694	5	0.2095	0.01	0.978275164	0.013747481	1.192022694	1192.022694
-1255.271282	5	0.2095	0.01	0.976818842	0.013487944	1.255271282	1255.271282
-1319.307134	5	0.2095	0.01	0.981546068	0.011972943	1.319307134	1319.307134
-1384.197821	5	0.2095	0.01	0.96445854	0.013357572	1.384197821	1384.197821
-1450.051342	5	0.2095	0.01	0.972756158	0.014756189	1.450051342	1450.051342
-1516.978619	5	0.2095	0.01	0.971010184	0.014210504	1.516978619	1516.978619
-1585.044577	5	0.2095	0.01	0.975009829	0.013648839	1.585044577	1585.044577
-1654.356194	5	0.2095	0.01	0.969575209	0.014454336	1.654356194	1654.356194
-1724.97535	5	0.2095	0.01	0.959907267	0.012806944	1.72497535	1724.97535
-1796.962718	5	0.2095	0.01	0.96219203	0.011845496	1.796962718	1796.962718
-1870.613025	5	0.2095	0.01	0.960157966	0.011721155	1.870613025	1870.613025
-1945.921163	5	0.2095	0.01	0.95089937	0.009967287	1.945921163	1945.921163
-2023.09841	5	0.2095	0.01	0.955584642	0.01104368	2.02309841	2023.09841
-2102.189874	5	0.2095	0.01	0.950786938	0.011651582	2.102189874	2102.189874
-2183.534527	5	0.2095	0.01	0.951403607	0.008784149	2.183534527	2183.534527
-2267.173021	5	0.2095	0.01	0.94561823	0.009392383	2.267173021	2267.173021
-2353.393035	5	0.2095	0.01	0.945882787	0.009468264	2.353393035	2353.393035
-2442.350387	5	0.2095	0.01	0.939583158	0.009164239	2.442350387	2442.350387
-2534.334343	5	0.2095	0.01	0.93731257	0.008621568	2.534334343	2534.334343
-2629.473374	5	0.2095	0.01	0.936639557	0.010408821	2.629473374	2629.473374
-2728.20036	5	0.2095	0.01	0.932091884	0.007112674	2.72820036	2728.20036
-2830.74992	5	0.2095	0.01	0.933852763	0.009200431	2.83074992	2830.74992
-2937.363026	5	0.2095	0.01	0.930283437	0.01022719	2.937363026	2937.363026
-3048.510973	5	0.2095	0.01	0.930121334	0.007055747	3.048510973	3048.510973
-3164.589926	5	0.2095	0.01	0.925944326	0.0078917	3.164589926	3164.589926
-3285.843756	5	0.2095	0.01	0.92351759	0.005522582	3.285843756	3285.843756
-3412.805801	5	0.2095	0.01	0.918228311	0.006917328	3.412805801	3412.805801
-3546.016792	5	0.2095	0.01	0.908621563	0.006852313	3.546016792	3546.016792
-3685.876213	5	0.2095	0.01	0.919087792	0.006995961	3.685876213	3685.876213
-3833.174832	5	0.2095	0.01	0.910132214	0.006107336	3.833174832	3833.174832
-3988.234456	5	0.2095	0.01	0.904491462	0.007078101	3.988234456	3988.234456
-4151.940408	5	0.2095	0.01	0.90056919	0.005934428	4.151940408	4151.940408
-4324.902332	5	0.2095	0.01	0.903155701	0.006249069	4.324902332	4324.902332
-4507.963711	5	0.2095	0.01	0.894919827	0.006196232	4.507963711	4507.963711
-4701.967049	5	0.2095	0.01	0.896358908	0.007324999	4.701967049	4701.967049
-4907.785127	5	0.2095	0.01	0.88807923	0.004530465	4.907785127	4907.785127
-5126.471149	5	0.2095	0.01	0.886649039	0.008924412	5.126471149	5126.471149
-5359.059759	5	0.2095	0.01	0.882051947	0.006055081	5.359059759	5359.059759
-5606.740304	5	0.2095	0.01	0.882573371	0.005727419	5.606740304	5606.740304
-5870.903592	5	0.2095	0.01	0.876289825	0.006062306	5.870903592	5870.903592
-6152.736235	5	0.2095	0.01	0.877318199	0.005307783	6.152736235	6152.736235
-6453.893169	5	0.2095	0.01	0.873610284	0.006918104	6.453893169	6453.893169
-6775.835415	5	0.2095	0.01	0.865954455	0.005625863	6.775835415	6775.835415
-7120.530581	5	0.2095	0.01	0.868037308	0.008662141	7.120530581	7120.530581
-7489.735939	5	0.2095	0.01	0.86676404	0.005761209	7.489735939	7489.735939
-7885.556041	5	0.2095	0.01	0.85655072	0.005731411	7.885556041	7885.556041
-8310.290162	5	0.2095	0.01	0.857936274	0.006954572	8.310290162	8310.290162
-8766.315359	5	0.2095	0.01	0.85520525	0.006559622	8.766315359	8766.315359
-9256.271706	5	0.2095	0.01	0.849739829	0.008907377	9.256271706	9256.271706
-9783.031577	5	0.2095	0.01	0.841487161	0.006547659	9.783031577	9783.031577
-10349.71409	5	0.2095	0.01	0.84801051	0.007574866	10.34971409	10349.71409
-10959.67923	5	0.2095	0.01	0.843342861	0.006411993	10.95967923	10959.67923
-11616.5969	5	0.2095	0.01	0.837488454	0.008141353	11.6165969	11616.5969
-12324.44664	5	0.2095	0.01	0.839214922	0.009987535	12.32444664	12324.44664
-13087.4723	5	0.2095	0.01	0.834336972	0.011467406	13.0874723	13087.4723
-13910.29604	5	0.2095	0.01	0.828775634	0.012483365	13.91029604	13910.29604
-14798.05061	5	0.2095	0.01	0.823088812	0.016035848	14.79805061	14798.05061
-15756.09817	5	0.2095	0.01	0.824329178	0.014838138	15.75609817	15756.09817
-16790.46783	5	0.2095	0.01	0.82023172	0.015286187	16.79046783	16790.46783
-17907.62866	5	0.2095	0.01	0.827559752	0.012612741	17.90762866	17907.62866
+BEGIN_DATA
+SpinEchoLength  SpinEchoLength_error  Wavelength  Wavelength_Error  Polarisation  Polarisation_error  Depolarisation  Depolarisation_error 
+298.82023 50 2.095 0.1 0.997817556 0.00887342 -4.9779370e-5 2.0261512e-4 
+852.57531 50 2.095 0.1 1.0026184 0.009572307 5.9579929e-5 2.1752686e-4 
+1407.9863 50 2.095 0.1 0.996013172 0.012119508 -9.1017859e-5 2.7723742e-4 
+1965.6050 50 2.095 0.1 0.991752656 0.011651473 -1.8868750e-4 2.6767598e-4 
+2525.2813 50 2.095 0.1 0.995429571 0.012193294 -1.0437182e-4 2.7908883e-4 
+3087.2927 50 2.095 0.1 0.995119819 0.009621277 -1.1146275e-4 2.2028721e-4 
+3650.3712 50 2.095 0.1 0.997497767 0.012518842 -5.7082583e-5 2.8594610e-4 
+4216.8468 50 2.095 0.1 0.994733865 0.010585926 -1.2030120e-4 2.4246770e-4 
+4785.6485 50 2.095 0.1 0.992696275 0.010677724 -1.6701950e-4 2.4507231e-4 
+5357.1552 50 2.095 0.1 0.994534294 0.012909556 -1.2487278e-4 2.9574914e-4 
+5932.4408 50 2.095 0.1 0.986412268 0.014651894 -3.1170682e-4 3.3842875e-4 
+6509.8550 50 2.095 0.1 0.988788566 0.015736468 -2.5688520e-4 3.6260666e-4 
+7092.0396 50 2.095 0.1 0.989501168 0.013611615 -2.4047103e-4 3.1341898e-4 
+7678.0994 50 2.095 0.1 0.99203933 0.016501891 -1.8210252e-4 3.7899787e-4 
+8267.6293 50 2.095 0.1 0.994062723 0.010276207 -1.3567871e-4 2.3553259e-4 
+8863.0230 50 2.095 0.1 0.979647192 0.011089421 -4.6850452e-4 2.5791174e-4 
+9462.3637 50 2.095 0.1 0.97982075 0.013168527 -4.6446835e-4 3.0621222e-4 
+10067.841 50 2.095 0.1 0.977927712 0.014362819 -5.0853039e-4 3.3463000e-4 
+10678.912 50 2.095 0.1 0.978351462 0.013239476 -4.9865985e-4 3.0832436e-4 
+11295.987 50 2.095 0.1 0.981246471 0.013896898 -4.3133968e-4 3.2267975e-4 
+11920.227 50 2.095 0.1 0.978275164 0.013747481 -5.0043677e-4 3.2017989e-4 
+12552.713 50 2.095 0.1 0.976818842 0.013487944 -5.3437989e-4 3.1460359e-4 
+13193.071 50 2.095 0.1 0.981546068 0.011972943 -4.2438423e-4 2.7792152e-4 
+13841.978 50 2.095 0.1 0.96445854 0.013357572 -8.2452102e-4 3.1555561e-4 
+14500.513 50 2.095 0.1 0.972756158 0.014756189 -6.2933879e-4 3.4562263e-4 
+15169.786 50 2.095 0.1 0.971010184 0.014210504 -6.7027011e-4 3.3343996e-4 
+15850.446 50 2.095 0.1 0.975009829 0.013648839 -5.7661387e-4 3.1894710e-4 
+16543.562 50 2.095 0.1 0.969575209 0.014454336 -7.0396574e-4 3.3966327e-4 
+17249.754 50 2.095 0.1 0.959907267 0.012806944 -9.3229353e-4 3.0398222e-4 
+17969.627 50 2.095 0.1 0.96219203 0.011845496 -8.7812744e-4 2.8049391e-4 
+18706.130 50 2.095 0.1 0.960157966 0.011721155 -9.2634378e-4 2.7813758e-4 
+19459.212 50 2.095 0.1 0.95089937 0.009967287 -1.1471121e-3 2.3882201e-4 
+20230.984 50 2.095 0.1 0.955584642 0.01104368 -1.0351259e-3 2.6331561e-4 
+21021.899 50 2.095 0.1 0.950786938 0.011651582 -1.1498062e-3 2.7921171e-4 
+21835.345 50 2.095 0.1 0.951403607 0.008784149 -1.1350335e-3 2.1036178e-4 
+22671.730 50 2.095 0.1 0.94561823 0.009392383 -1.2740040e-3 2.2630383e-4 
+23533.930 50 2.095 0.1 0.945882787 0.009468264 -1.2676305e-3 2.2806833e-4 
+24423.504 50 2.095 0.1 0.939583158 0.009164239 -1.4198814e-3 2.2222511e-4 
+25343.343 50 2.095 0.1 0.93731257 0.008621568 -1.4750079e-3 2.0957224e-4 
+26294.734 50 2.095 0.1 0.936639557 0.010408821 -1.4913733e-3 2.5319842e-4 
+27282.004 50 2.095 0.1 0.932091884 0.007112674 -1.6022666e-3 1.7386258e-4 
+28307.499 50 2.095 0.1 0.933852763 0.009200431 -1.5592642e-3 2.2447176e-4 
+29373.630 50 2.095 0.1 0.930283437 0.01022719 -1.6465153e-3 2.5047996e-4 
+30485.110 50 2.095 0.1 0.930121334 0.007055747 -1.6504858e-3 1.7283645e-4 
+31645.899 50 2.095 0.1 0.925944326 0.0078917 -1.7530356e-3 1.9418588e-4 
+32858.438 50 2.095 0.1 0.92351759 0.005522582 -1.8128270e-3 1.3624763e-4 
+34128.058 50 2.095 0.1 0.918228311 0.006917328 -1.9436940e-3 1.7164045e-4 
+35460.168 50 2.095 0.1 0.908621563 0.006852313 -2.1833230e-3 1.7182490e-4 
+36858.762 50 2.095 0.1 0.919087792 0.006995961 -1.9223776e-3 1.7342924e-4 
+38331.748 50 2.095 0.1 0.910132214 0.006107336 -2.1454742e-3 1.5289007e-4 
+39882.345 50 2.095 0.1 0.904491462 0.007078101 -2.2871233e-3 1.7829708e-4 
+41519.404 50 2.095 0.1 0.90056919 0.005934428 -2.3861400e-3 1.5013907e-4 
+43249.023 50 2.095 0.1 0.903155701 0.006249069 -2.3207959e-3 1.5764661e-4 
+45079.637 50 2.095 0.1 0.894919827 0.006196232 -2.5295172e-3 1.5775222e-4 
+47019.670 50 2.095 0.1 0.896358908 0.007324999 -2.4929085e-3 1.8619052e-4 
+49077.851 50 2.095 0.1 0.88807923 0.004530465 -2.7043436e-3 1.1623128e-4 
+51264.711 50 2.095 0.1 0.886649039 0.008924412 -2.7410654e-3 2.2932944e-4 
+53590.598 50 2.095 0.1 0.882051947 0.006055081 -2.8595036e-3 1.5640756e-4 
+56067.403 50 2.095 0.1 0.882573371 0.005727419 -2.8460388e-3 1.4785638e-4 
+58709.036 50 2.095 0.1 0.876289825 0.006062306 -3.0088321e-3 1.5762389e-4 
+61527.362 50 2.095 0.1 0.877318199 0.005307783 -2.9821094e-3 1.3784403e-4 
+64538.932 50 2.095 0.1 0.873610284 0.006918104 -3.0786086e-3 1.8042690e-4 
+67758.354 50 2.095 0.1 0.865954455 0.005625863 -3.2791557e-3 1.4802192e-4 
+71205.306 50 2.095 0.1 0.868037308 0.008662141 -3.2244196e-3 2.2736248e-4 
+74897.359 50 2.095 0.1 0.86676404 0.005761209 -3.2578647e-3 1.5144143e-4 
+78855.560 50 2.095 0.1 0.85655072 0.005731411 -3.5279304e-3 1.5245456e-4 
+83102.902 50 2.095 0.1 0.857936274 0.006954572 -3.4911046e-3 1.8469168e-4 
+87663.154 50 2.095 0.1 0.85520525 0.006559622 -3.5637478e-3 1.7475934e-4 
+92562.717 50 2.095 0.1 0.849739829 0.008907377 -3.7098230e-3 2.3883381e-4 
+97830.316 50 2.095 0.1 0.841487161 0.006547659 -3.9321836e-3 1.7728439e-4 
+103497.14 50 2.095 0.1 0.84801051 0.007574866 -3.7562386e-3 2.0351933e-4 
+109596.79 50 2.095 0.1 0.843342861 0.006411993 -3.8819940e-3 1.7322909e-4 
+116165.97 50 2.095 0.1 0.837488454 0.008141353 -4.0407107e-3 2.2148775e-4 
+123244.47 50 2.095 0.1 0.839214922 0.009987535 -3.9937900e-3 2.7115465e-4 
+130874.72 50 2.095 0.1 0.834336972 0.011467406 -4.1266093e-3 3.1315233e-4 
+139102.96 50 2.095 0.1 0.828775634 0.012483365 -4.2789870e-3 3.4318369e-4 
+147980.51 50 2.095 0.1 0.823088812 0.016035848 -4.4358638e-3 4.4389186e-4 
+157560.98 50 2.095 0.1 0.824329178 0.014838138 -4.4015548e-3 4.1011975e-4 
+167904.68 50 2.095 0.1 0.82023172 0.015286187 -4.5150892e-3 4.2461424e-4 
+179076.29 50 2.095 0.1 0.827559752 0.012612741 -4.3124376e-3 3.4724985e-4 
Index: explore/asymint.py
===================================================================
--- explore/asymint.py	(revision a1c32c237d9d08ea8ba721dbaf09ecf6b862c812)
+++ explore/asymint.py	(revision c4621699875d34774d863620c13cccb42524c8bf)
@@ -30,5 +30,5 @@
 import numpy as np
 import mpmath as mp
-from numpy import pi, sin, cos, sqrt, exp, expm1, degrees, log10
+from numpy import pi, sin, cos, sqrt, exp, expm1, degrees, log10, arccos
 from numpy.polynomial.legendre import leggauss
 from scipy.integrate import dblquad, simps, romb, romberg
@@ -41,4 +41,6 @@
 shape = 'parallelepiped' if len(sys.argv) < 2 else sys.argv[1]
 Qstr = '0.005' if len(sys.argv) < 3 else sys.argv[2]
+
+DTYPE = 'd'
 
 class MPenv:
@@ -72,5 +74,6 @@
     sas_sinx_x = staticmethod(sp.sas_sinx_x)
     pi = np.pi
-    mpf = staticmethod(float)
+    #mpf = staticmethod(float)
+    mpf = staticmethod(lambda x: np.array(x, DTYPE))
 
 SLD = 3
@@ -220,7 +223,11 @@
     #A, B, C = 4450, 14000, 47
     #A, B, C = 445, 140, 47  # integer for the sake of mpf
-    A, B, C = 6800, 114, 1380
-    DA, DB, DC = 2300, 21, 58
+    A, B, C = 114, 1380, 6800
+    DA, DB, DC = 21, 58, 2300
     SLDA, SLDB, SLDC = "5", "-0.3", "11.5"
+    ## default parameters from sasmodels
+    #A,B,C,DA,DB,DC,SLDA,SLDB,SLDC = 400,75,35,10,10,10,2,4,2
+    ## swap A-B-C to C-B-A
+    #A, B, C, DA, DB, DC, SLDA, SLDB, SLDC = C, B, A, DC, DB, DA, SLDC, SLDB, SLDA
     #A,B,C,DA,DB,DC,SLDA,SLDB,SLDC = 10,20,30,100,200,300,1,2,3
     #SLD_SOLVENT,CONTRAST = 0, 4
@@ -233,4 +240,5 @@
         DA, DC = DC, DA
         SLDA, SLDC = SLDC, SLDA
+    #NORM, KERNEL = make_core_shell_parallelepiped(A, B, C, DA, DB, DC, SLDA, SLDB, SLDC)
     NORM, KERNEL = make_core_shell_parallelepiped(A, B, C, DA, DB, DC, SLDA, SLDB, SLDC)
     NORM_MP, KERNEL_MP = make_core_shell_parallelepiped(A, B, C, DA, DB, DC, SLDA, SLDB, SLDC, env=MPenv)
@@ -348,4 +356,25 @@
     return n**2, Iq
 
+def gauss_quad_usub(q, n=150, dtype=DTYPE):
+    """
+    Compute the integral using gaussian quadrature for n = 20, 76 or 150.
+
+    Use *u = sin theta* substitution, and restrict integration over a single
+    quadrant for shapes that are mirror symmetric about AB, AC and BC planes.
+
+    Note that this doesn't work for fcc/bcc paracrystals, which instead step
+    over the entire 4 pi surface uniformly in theta-phi.
+    """
+    z, w = leggauss(n)
+    cos_theta = 0.5 * (z + 1)
+    theta = arccos(cos_theta)
+    phi = pi/2*(0.5 * (z + 1))
+    Atheta, Aphi = np.meshgrid(theta, phi)
+    Aw = w[None, :] * w[:, None]
+    q, Atheta, Aphi, Aw = [np.asarray(v, dtype=dtype) for v in (q, Atheta, Aphi, Aw)]
+    Zq = kernel_2d(q=q, theta=Atheta, phi=Aphi)
+    Iq = np.sum(Zq*Aw)*0.25
+    return n**2, Iq
+
 def gridded_2d(q, n=300):
     """
@@ -395,4 +424,7 @@
     print("gauss-1025", *gauss_quad_2d(Q, n=1025))
     print("gauss-2049", *gauss_quad_2d(Q, n=2049))
+    print("gauss-20 usub", *gauss_quad_usub(Q, n=20))
+    print("gauss-76 usub", *gauss_quad_usub(Q, n=76))
+    print("gauss-150 usub", *gauss_quad_usub(Q, n=150))
     #gridded_2d(Q, n=2**8+1)
     gridded_2d(Q, n=2**10+1)
Index: plore/guyou.py
===================================================================
--- explore/guyou.py	(revision 9d9fcbd8b087f9dd571a6eefd725819968dfea54)
+++ 	(revision )
@@ -1,375 +1,0 @@
-# Copyright 2013-2016 Mike Bostock
-# All rights reserved.
-#
-# Redistribution and use in source and binary forms, with or without modification,
-# are permitted provided that the following conditions are met:
-#
-# * Redistributions of source code must retain the above copyright notice, this
-#   list of conditions and the following disclaimer.
-#
-# * Redistributions in binary form must reproduce the above copyright notice,
-#   this list of conditions and the following disclaimer in the documentation
-#   and/or other materials provided with the distribution.
-#
-# * Neither the name of the author nor the names of contributors may be used to
-#   endorse or promote products derived from this software without specific prior
-#   written permission.
-#
-# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
-# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
-# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
-# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
-# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
-# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
-# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
-# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-#
-# https://github.com/d3/d3-geo-projection
-# commit fd2886555e46b35163c7b898d43c7d1bcebbba7c 2016-07-02
-#
-# 2017-11-01 Paul Kienzle
-# * converted to python, using degrees rather than radians
-from __future__ import division, print_function
-
-import numpy as np
-from numpy import sqrt, pi, tan, cos, sin, log, exp, arctan2 as atan2, sign, radians, degrees
-
-_ = """
-# mpmath version of special functions
-import mpmath as mp
-sn, cn, dn = [mp.ellipfun(v) for v in 'sn', 'cn', 'dn']
-
-def ellipticJi(u, v, m):
-    z = u+v*1j
-    return sn(z, m), cn(z, m), dn(z, m)
-
-def ellipticJ(u, m):
-    s, c, d = sn(u, m), cn(u, m), dn(u, m)
-    phi = mp.asin(s)
-    return s, c, d, phi
-
-def ellipticFi(phi, psi, m):
-    z = phi + psi*1j
-    return mp.ellipf(z, m)
-
-def ellipticF(phi, m):
-    return mp.ellipf(phi, m)
-"""
-
-# scipy version of special functions
-from scipy.special import ellipj as ellipticJ, ellipkinc as ellipticF
-from numpy import sinh, sign, arctan as atan
-
-def ellipticJi(u, v, m):
-    scalar = np.isscalar(u) and np.isscalar(v) and np.isscalar(m)
-    u, v, m = np.broadcast_arrays(u, v, m)
-    result = np.empty_like([u,u,u], 'D')
-    real = v==0
-    imag = u==0
-    mixed = ~(real|imag)
-    result[:, real] = _ellipticJi_real(u[real], m[real])
-    result[:, imag] = _ellipticJi_imag(v[imag], m[imag])
-    result[:, mixed] = _ellipticJi(u[mixed], v[mixed], m[mixed])
-    return result[0,:] if scalar else result
-
-def _ellipticJi_real(u, m):
-    sn, cn, dn, phi = ellipticJ(u, m)
-    return sn, cn, dn
-
-def _ellipticJi_imag(v, m):
-    sn, cn, dn, phi = ellipticJ(v, 1-m)
-    return 1j*sn/cn, 1/cn, dn/cn
-
-def _ellipticJi(u, v, m):
-    # Ignoring special cases for now.
-    #   u=0: (1j*b[0]/b[1], 1/b[1], b[2]/b[1])
-    #   v=0: (a[0], a[1], a[2])
-    a = ellipticJ(u, m)
-    b = ellipticJ(v, 1 - m)
-    c = b[1]**2 + m * (a[0] * b[0])**2
-    return [
-        (a[0] * b[2] / c) + 1j*(a[1] * a[2] * b[0] * b[1] / c),
-        (a[1] * b[1] / c) + 1j*(-a[0] * a[2] * b[0] * b[2] / c),
-        (a[2] * b[1] * b[2] / c) + 1j*(-m * a[0] * a[1] * b[0] / c),
-    ]
-
-# calculate F(phi+ipsi|m).
-# see Abramowitz and Stegun, 17.4.11.
-def ellipticFi(phi, psi, m):
-    if np.any(phi == 0):
-        scalar = np.isscalar(phi) and np.isscalar(psi) and np.isscalar(m)
-        phi, psi, m = np.broadcast_arrays(phi, psi, m)
-        result = np.empty_like(phi, 'D')
-        index = (phi == 0)
-        result[index] = ellipticF(atan(sinh(abs(phi[index]))), 1-m[index]) * sign(psi[index])
-        result[~index] = ellipticFi(phi[~index], psi[~index], m[~index])
-        return result.reshape(1)[0] if scalar else result
-
-    r = abs(phi)
-    i = abs(psi)
-    sinhpsi2 = sinh(i)**2
-    cscphi2 = 1 / sin(r)**2
-    cotphi2 = 1 / tan(r)**2
-    b = -(cotphi2 + m * (sinhpsi2 * cscphi2) - 1 + m)
-    c = (m - 1) * cotphi2
-    cotlambda2 = (-b + sqrt(b * b - 4 * c)) / 2
-    re = ellipticF(atan(1 / sqrt(cotlambda2)), m) * sign(phi)
-    im = ellipticF(atan(sqrt(np.maximum(0,(cotlambda2 / cotphi2 - 1) / m))), 1 - m) * sign(psi)
-    return re + 1j*im
-
-sqrt2 = sqrt(2)
-
-# [PAK] renamed k_ => cos_u, k => sin_u, k*k => sinsq_u to avoid k,K confusion
-# cos_u = 0.171572875253809902396622551580603842860656249246103853646...
-# sinsq_u = 0.970562748477140585620264690516376942836062504523376878120...
-# K = 3.165103454447431823666270142140819753058976299237578486994...
-def guyou(lam, phi):
-    # [PAK] wrap into [-pi/2, pi/2] radians
-    x, y = np.asarray(lam), np.asarray(phi)
-    xn, x = divmod(x+90, 180)
-    yn, y = divmod(y+90, 180)
-    xn, lam = xn*180, radians(x-90)
-    yn, phi = yn*180, radians(y-90)
-
-    # Compute constant K
-    cos_u = (sqrt2 - 1) / (sqrt2 + 1)
-    sinsq_u = 1 - cos_u**2
-    K = ellipticF(pi/2, sinsq_u)
-
-    # [PAK] simplify expressions, using the fact that f = -1
-    # Note: exp(f log(x)) => 1/x,  cos(f x) => cos(x), sin(f x) => -sin(x)
-    r = 1/(tan(pi/4 + abs(phi)/2) * sqrt(cos_u))
-    at = atan(r * (cos(lam) - 1j*sin(lam)))
-    t = ellipticFi(at.real, at.imag, sinsq_u)
-    x, y = (-t.imag, sign(phi + (phi==0))*(0.5 * K - t.real))
-
-    # [PAK] convert to degrees, and return to original tile
-    return degrees(x)+xn, degrees(y)+yn
-
-def guyou_invert(x, y):
-    # [PAK] wrap into [-pi/2, pi/2] radians
-    x, y = np.asarray(x), np.asarray(y)
-    xn, x = divmod(x+90, 180)
-    yn, y = divmod(y+90, 180)
-    xn, x = xn*180, radians(x-90)
-    yn, y = yn*180, radians(y-90)
-
-    # compute constant K
-    cos_u = (sqrt2 - 1) / (sqrt2 + 1)
-    sinsq_u = 1 - cos_u**2
-    K = ellipticF(pi/2, sinsq_u)
-
-    # [PAK] simplify expressions, using the fact that f = -1
-    j = ellipticJi(K/2 - y, -x, sinsq_u)
-    tn = j[0]/j[1]  # j[0], j[1] are complex
-    # Note: -atan2(im(x)/re(x)) => angle(x)
-    lam = -np.angle(tn)
-    # Note: exp(0.5/f log(a re(x)^2 + a im(x)^2)) => 1/(sqrt(a) |x|)
-    phi = 2*atan(1/sqrt(cos_u)/abs(tn)) - pi/2
-
-    # [PAK] convert to degrees, and return to original tile
-    return degrees(lam)+xn, degrees(phi)+yn
-
-def plot_grid():
-    import matplotlib.pyplot as plt
-    from numpy import linspace
-    lat_line = linspace(-90, 90, 400)
-    long_line = linspace(-90, 90, 400)
-
-    #scale = 1
-    limit, step, scale = 90, 10, 2
-    plt.subplot(211)
-    for lat in range(-limit, limit+1, step):
-        x, y = guyou(scale*lat_line, scale*lat)
-        plt.plot(x, y, 'g')
-
-    for long in range(-limit, limit+1, step):
-        x, y = guyou(scale*long, scale*long_line)
-        plt.plot(x, y, 'b')
-    #plt.xlabel('longitude')
-    plt.ylabel('latitude')
-
-    plt.subplot(212)
-    for lat in range(-limit, limit+1, step):
-        x, y = guyou_invert(scale*lat_line, scale*lat)
-        plt.plot(x, y, 'g')
-
-    for long in range(-limit, limit+1, step):
-        x, y = guyou_invert(scale*long, scale*long_line)
-        plt.plot(x, y, 'b')
-    plt.xlabel('longitude')
-    plt.ylabel('latitude')
-
-if __name__ == "__main__":
-    plot_grid()
-    import matplotlib.pyplot as plt; plt.show()
-
-
-_ = """
-// Javascript source for elliptic functions
-//
-// Returns [sn, cn, dn](u + iv|m).
-export function ellipticJi(u, v, m) {
-  var a, b, c;
-  if (!u) {
-    b = ellipticJ(v, 1 - m);
-    return [
-      [0, b[0] / b[1]],
-      [1 / b[1], 0],
-      [b[2] / b[1], 0]
-    ];
-  }
-  a = ellipticJ(u, m);
-  if (!v) return [[a[0], 0], [a[1], 0], [a[2], 0]];
-  b = ellipticJ(v, 1 - m);
-  c = b[1] * b[1] + m * a[0] * a[0] * b[0] * b[0];
-  return [
-    [a[0] * b[2] / c, a[1] * a[2] * b[0] * b[1] / c],
-    [a[1] * b[1] / c, -a[0] * a[2] * b[0] * b[2] / c],
-    [a[2] * b[1] * b[2] / c, -m * a[0] * a[1] * b[0] / c]
-  ];
-}
-
-// Returns [sn, cn, dn, ph](u|m).
-export function ellipticJ(u, m) {
-  var ai, b, phi, t, twon;
-  if (m < epsilon) {
-    t = sin(u);
-    b = cos(u);
-    ai = m * (u - t * b) / 4;
-    return [
-      t - ai * b,
-      b + ai * t,
-      1 - m * t * t / 2,
-      u - ai
-    ];
-  }
-  if (m >= 1 - epsilon) {
-    ai = (1 - m) / 4;
-    b = cosh(u);
-    t = tanh(u);
-    phi = 1 / b;
-    twon = b * sinh(u);
-    return [
-      t + ai * (twon - u) / (b * b),
-      phi - ai * t * phi * (twon - u),
-      phi + ai * t * phi * (twon + u),
-      2 * atan(exp(u)) - halfPi + ai * (twon - u) / b
-    ];
-  }
-
-  var a = [1, 0, 0, 0, 0, 0, 0, 0, 0],
-      c = [sqrt(m), 0, 0, 0, 0, 0, 0, 0, 0],
-      i = 0;
-  b = sqrt(1 - m);
-  twon = 1;
-
-  while (abs(c[i] / a[i]) > epsilon && i < 8) {
-    ai = a[i++];
-    c[i] = (ai - b) / 2;
-    a[i] = (ai + b) / 2;
-    b = sqrt(ai * b);
-    twon *= 2;
-  }
-
-  phi = twon * a[i] * u;
-  do {
-    t = c[i] * sin(b = phi) / a[i];
-    phi = (asin(t) + phi) / 2;
-  } while (--i);
-
-  // [PAK] Cephes uses dn = sqrt(1 - m*sin^2 phi) rather than cos(phi)/cos(phi-b)
-  // DLMF says the second version is unstable near x = K.
-  return [sin(phi), t = cos(phi), t / cos(phi - b), phi];
-}
-
-// calculate F(phi+ipsi|m).
-// see Abramowitz and Stegun, 17.4.11.
-export function ellipticFi(phi, psi, m) {
-  var r = abs(phi),
-      i = abs(psi),
-      sinhpsi = sinh(i);
-  if (r) {
-    var cscphi = 1 / sin(r),
-        cotphi2 = 1 / (tan(r) * tan(r)),
-        b = -(cotphi2 + m * (sinhpsi * sinhpsi * cscphi * cscphi) - 1 + m),
-        c = (m - 1) * cotphi2,
-        cotlambda2 = (-b + sqrt(b * b - 4 * c)) / 2;
-    return [
-      ellipticF(atan(1 / sqrt(cotlambda2)), m) * sign(phi),
-      ellipticF(atan(sqrt((cotlambda2 / cotphi2 - 1) / m)), 1 - m) * sign(psi)
-    ];
-  }
-  return [
-    0,
-    ellipticF(atan(sinhpsi), 1 - m) * sign(psi)
-  ];
-}
-
-// Calculate F(phi|m) where m = k^2 = sin(alpha)^2.
-// See Abramowitz and Stegun, 17.6.7.
-export function ellipticF(phi, m) {
-  if (!m) return phi;
-  if (m === 1) return log(tan(phi / 2 + quarterPi));
-  var a = 1,
-      b = sqrt(1 - m),
-      c = sqrt(m);
-  for (var i = 0; abs(c) > epsilon; i++) {
-    if (phi % pi) {
-      var dPhi = atan(b * tan(phi) / a);
-      if (dPhi < 0) dPhi += pi;
-      phi += dPhi + ~~(phi / pi) * pi;
-    } else phi += phi;
-    c = (a + b) / 2;
-    b = sqrt(a * b);
-    c = ((a = c) - b) / 2;
-  }
-  return phi / (pow(2, i) * a);
-
-
-export function guyouRaw(lambda, phi) {
-  var k_ = (sqrt2 - 1) / (sqrt2 + 1),
-      k = sqrt(1 - k_ * k_),
-      K = ellipticF(halfPi, k * k),
-      f = -1,
-      psi = log(tan(pi / 4 + abs(phi) / 2)),
-      r = exp(f * psi) / sqrt(k_),
-      at = guyouComplexAtan(r * cos(f * lambda), r * sin(f * lambda)),
-      t = ellipticFi(at[0], at[1], k * k);
-  return [-t[1], (phi >= 0 ? 1 : -1) * (0.5 * K - t[0])];
-}
-
-function guyouComplexAtan(x, y) {
-  var x2 = x * x,
-      y_1 = y + 1,
-      t = 1 - x2 - y * y;
-  return [
-   0.5 * ((x >= 0 ? halfPi : -halfPi) - atan2(t, 2 * x)),
-    -0.25 * log(t * t + 4 * x2) +0.5 * log(y_1 * y_1 + x2)
-  ];
-}
-
-function guyouComplexDivide(a, b) {
-  var denominator = b[0] * b[0] + b[1] * b[1];
-  return [
-    (a[0] * b[0] + a[1] * b[1]) / denominator,
-    (a[1] * b[0] - a[0] * b[1]) / denominator
-  ];
-}
-
-guyouRaw.invert = function(x, y) {
-  var k_ = (sqrt2 - 1) / (sqrt2 + 1),
-      k = sqrt(1 - k_ * k_),
-      K = ellipticF(halfPi, k * k),
-      f = -1,
-      j = ellipticJi(0.5 * K - y, -x, k * k),
-      tn = guyouComplexDivide(j[0], j[1]),
-      lambda = atan2(tn[1], tn[0]) / f;
-  return [
-    lambda,
-    2 * atan(exp(0.5 / f * log(k_ * tn[0] * tn[0] + k_ * tn[1] * tn[1]))) - halfPi
-  ];
-};
-"""
Index: plore/jitter.py
===================================================================
--- explore/jitter.py	(revision 8cfb486c14c2f481e9135c412b0ed9506517c141)
+++ 	(revision )
@@ -1,716 +1,0 @@
-#!/usr/bin/env python
-"""
-Application to explore the difference between sasview 3.x orientation
-dispersity and possible replacement algorithms.
-"""
-from __future__ import division, print_function
-
-import sys, os
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
-
-import argparse
-
-import mpl_toolkits.mplot3d   # Adds projection='3d' option to subplot
-import matplotlib.pyplot as plt
-from matplotlib.widgets import Slider, CheckButtons
-from matplotlib import cm
-import numpy as np
-from numpy import pi, cos, sin, sqrt, exp, degrees, radians
-
-def draw_beam(ax, view=(0, 0)):
-    """
-    Draw the beam going from source at (0, 0, 1) to detector at (0, 0, -1)
-    """
-    #ax.plot([0,0],[0,0],[1,-1])
-    #ax.scatter([0]*100,[0]*100,np.linspace(1, -1, 100), alpha=0.8)
-
-    steps = 25
-    u = np.linspace(0, 2 * np.pi, steps)
-    v = np.linspace(-1, 1, steps)
-
-    r = 0.02
-    x = r*np.outer(np.cos(u), np.ones_like(v))
-    y = r*np.outer(np.sin(u), np.ones_like(v))
-    z = 1.3*np.outer(np.ones_like(u), v)
-
-    theta, phi = view
-    shape = x.shape
-    points = np.matrix([x.flatten(), y.flatten(), z.flatten()])
-    points = Rz(phi)*Ry(theta)*points
-    x, y, z = [v.reshape(shape) for v in points]
-
-    ax.plot_surface(x, y, z, rstride=4, cstride=4, color='y', alpha=0.5)
-
-def draw_jitter(ax, view, jitter, dist='gaussian', size=(0.1, 0.4, 1.0)):
-    """
-    Represent jitter as a set of shapes at different orientations.
-    """
-    # set max diagonal to 0.95
-    scale = 0.95/sqrt(sum(v**2 for v in size))
-    size = tuple(scale*v for v in size)
-    draw_shape = draw_parallelepiped
-    #draw_shape = draw_ellipsoid
-
-    #np.random.seed(10)
-    #cloud = np.random.randn(10,3)
-    cloud = [
-        [-1, -1, -1],
-        [-1, -1,  0],
-        [-1, -1,  1],
-        [-1,  0, -1],
-        [-1,  0,  0],
-        [-1,  0,  1],
-        [-1,  1, -1],
-        [-1,  1,  0],
-        [-1,  1,  1],
-        [ 0, -1, -1],
-        [ 0, -1,  0],
-        [ 0, -1,  1],
-        [ 0,  0, -1],
-        [ 0,  0,  0],
-        [ 0,  0,  1],
-        [ 0,  1, -1],
-        [ 0,  1,  0],
-        [ 0,  1,  1],
-        [ 1, -1, -1],
-        [ 1, -1,  0],
-        [ 1, -1,  1],
-        [ 1,  0, -1],
-        [ 1,  0,  0],
-        [ 1,  0,  1],
-        [ 1,  1, -1],
-        [ 1,  1,  0],
-        [ 1,  1,  1],
-    ]
-    dtheta, dphi, dpsi = jitter
-    if dtheta == 0:
-        cloud = [v for v in cloud if v[0] == 0]
-    if dphi == 0:
-        cloud = [v for v in cloud if v[1] == 0]
-    if dpsi == 0:
-        cloud = [v for v in cloud if v[2] == 0]
-    draw_shape(ax, size, view, [0, 0, 0], steps=100, alpha=0.8)
-    scale = {'gaussian':1, 'rectangle':1/sqrt(3), 'uniform':1/3}[dist]
-    for point in cloud:
-        delta = [scale*dtheta*point[0], scale*dphi*point[1], scale*dpsi*point[2]]
-        draw_shape(ax, size, view, delta, alpha=0.8)
-    for v in 'xyz':
-        a, b, c = size
-        lim = np.sqrt(a**2+b**2+c**2)
-        getattr(ax, 'set_'+v+'lim')([-lim, lim])
-        getattr(ax, v+'axis').label.set_text(v)
-
-def draw_ellipsoid(ax, size, view, jitter, steps=25, alpha=1):
-    """Draw an ellipsoid."""
-    a,b,c = size
-    u = np.linspace(0, 2 * np.pi, steps)
-    v = np.linspace(0, np.pi, steps)
-    x = a*np.outer(np.cos(u), np.sin(v))
-    y = b*np.outer(np.sin(u), np.sin(v))
-    z = c*np.outer(np.ones_like(u), np.cos(v))
-    x, y, z = transform_xyz(view, jitter, x, y, z)
-
-    ax.plot_surface(x, y, z, rstride=4, cstride=4, color='w', alpha=alpha)
-
-    draw_labels(ax, view, jitter, [
-         ('c+', [ 0, 0, c], [ 1, 0, 0]),
-         ('c-', [ 0, 0,-c], [ 0, 0,-1]),
-         ('a+', [ a, 0, 0], [ 0, 0, 1]),
-         ('a-', [-a, 0, 0], [ 0, 0,-1]),
-         ('b+', [ 0, b, 0], [-1, 0, 0]),
-         ('b-', [ 0,-b, 0], [-1, 0, 0]),
-    ])
-
-def draw_parallelepiped(ax, size, view, jitter, steps=None, alpha=1):
-    """Draw a parallelepiped."""
-    a,b,c = size
-    x = a*np.array([ 1,-1, 1,-1, 1,-1, 1,-1])
-    y = b*np.array([ 1, 1,-1,-1, 1, 1,-1,-1])
-    z = c*np.array([ 1, 1, 1, 1,-1,-1,-1,-1])
-    tri = np.array([
-        # counter clockwise triangles
-        # z: up/down, x: right/left, y: front/back
-        [0,1,2], [3,2,1], # top face
-        [6,5,4], [5,6,7], # bottom face
-        [0,2,6], [6,4,0], # right face
-        [1,5,7], [7,3,1], # left face
-        [2,3,6], [7,6,3], # front face
-        [4,1,0], [5,1,4], # back face
-    ])
-
-    x, y, z = transform_xyz(view, jitter, x, y, z)
-    ax.plot_trisurf(x, y, triangles=tri, Z=z, color='w', alpha=alpha)
-
-    draw_labels(ax, view, jitter, [
-         ('c+', [ 0, 0, c], [ 1, 0, 0]),
-         ('c-', [ 0, 0,-c], [ 0, 0,-1]),
-         ('a+', [ a, 0, 0], [ 0, 0, 1]),
-         ('a-', [-a, 0, 0], [ 0, 0,-1]),
-         ('b+', [ 0, b, 0], [-1, 0, 0]),
-         ('b-', [ 0,-b, 0], [-1, 0, 0]),
-    ])
-
-def draw_sphere(ax, radius=10., steps=100):
-    """Draw a sphere"""
-    u = np.linspace(0, 2 * np.pi, steps)
-    v = np.linspace(0, np.pi, steps)
-
-    x = radius * np.outer(np.cos(u), np.sin(v))
-    y = radius * np.outer(np.sin(u), np.sin(v))
-    z = radius * np.outer(np.ones(np.size(u)), np.cos(v))
-    ax.plot_surface(x, y, z, rstride=4, cstride=4, color='w')
-
-PROJECTIONS = [
-    # in order of PROJECTION number; do not change without updating the
-    # constants in kernel_iq.c
-    'equirectangular', 'sinusoidal', 'guyou', 'azimuthal_equidistance',
-    'azimuthal_equal_area',
-]
-def draw_mesh(ax, view, jitter, radius=1.2, n=11, dist='gaussian',
-              projection='equirectangular'):
-    """
-    Draw the dispersion mesh showing the theta-phi orientations at which
-    the model will be evaluated.
-
-    jitter projections
-    <https://en.wikipedia.org/wiki/List_of_map_projections>
-
-    equirectangular (standard latitude-longitude mesh)
-        <https://en.wikipedia.org/wiki/Equirectangular_projection>
-        Allows free movement in phi (around the equator), but theta is
-        limited to +/- 90, and points are cos-weighted. Jitter in phi is
-        uniform in weight along a line of latitude.  With small theta and
-        phi ranging over +/- 180 this forms a wobbling disk.  With small
-        phi and theta ranging over +/- 90 this forms a wedge like a slice
-        of an orange.
-    azimuthal_equidistance (Postel)
-        <https://en.wikipedia.org/wiki/Azimuthal_equidistant_projection>
-        Preserves distance from center, and so is an excellent map for
-        representing a bivariate gaussian on the surface.  Theta and phi
-        operate identically, cutting wegdes from the antipode of the viewing
-        angle.  This unfortunately does not allow free movement in either
-        theta or phi since the orthogonal wobble decreases to 0 as the body
-        rotates through 180 degrees.
-    sinusoidal (Sanson-Flamsteed, Mercator equal-area)
-        <https://en.wikipedia.org/wiki/Sinusoidal_projection>
-        Preserves arc length with latitude, giving bad behaviour at
-        theta near +/- 90.  Theta and phi operate somewhat differently,
-        so a system with a-b-c dtheta-dphi-dpsi will not give the same
-        value as one with b-a-c dphi-dtheta-dpsi, as would be the case
-        for azimuthal equidistance.  Free movement using theta or phi
-        uniform over +/- 180 will work, but not as well as equirectangular
-        phi, with theta being slightly worse.  Computationally it is much
-        cheaper for wide theta-phi meshes since it excludes points which
-        lie outside the sinusoid near theta +/- 90 rather than packing
-        them close together as in equirectangle.  Note that the poles
-        will be slightly overweighted for theta > 90 with the circle
-        from theta at 90+dt winding backwards around the pole, overlapping
-        the circle from theta at 90-dt.
-    Guyou (hemisphere-in-a-square) **not weighted**
-        <https://en.wikipedia.org/wiki/Guyou_hemisphere-in-a-square_projection>
-        With tiling, allows rotation in phi or theta through +/- 180, with
-        uniform spacing.  Both theta and phi allow free rotation, with wobble
-        in the orthogonal direction reasonably well behaved (though not as
-        good as equirectangular phi). The forward/reverse transformations
-        relies on elliptic integrals that are somewhat expensive, so the
-        behaviour has to be very good to justify the cost and complexity.
-        The weighting function for each point has not yet been computed.
-        Note: run the module *guyou.py* directly and it will show the forward
-        and reverse mappings.
-    azimuthal_equal_area  **incomplete**
-        <https://en.wikipedia.org/wiki/Lambert_azimuthal_equal-area_projection>
-        Preserves the relative density of the surface patches.  Not that
-        useful and not completely implemented
-    Gauss-Kreuger **not implemented**
-        <https://en.wikipedia.org/wiki/Transverse_Mercator_projection#Ellipsoidal_transverse_Mercator>
-        Should allow free movement in theta, but phi is distorted.
-    """
-    theta, phi, psi = view
-    dtheta, dphi, dpsi = jitter
-
-    t = np.linspace(-1, 1, n)
-    weights = np.ones_like(t)
-    if dist == 'gaussian':
-        t *= 3
-        weights = exp(-0.5*t**2)
-    elif dist == 'rectangle':
-        # Note: uses sasmodels ridiculous definition of rectangle width
-        t *= sqrt(3)
-    elif dist == 'uniform':
-        pass
-    else:
-        raise ValueError("expected dist to be gaussian, rectangle or uniform")
-
-    if projection == 'equirectangular':  #define PROJECTION 1
-        def rotate(theta_i, phi_j):
-            return Rx(phi_j)*Ry(theta_i)
-        def weight(theta_i, phi_j, wi, wj):
-            return wi*wj*abs(cos(radians(theta_i)))
-    elif projection == 'sinusoidal':  #define PROJECTION 2
-        def rotate(theta_i, phi_j):
-            latitude = theta_i
-            scale = cos(radians(latitude))
-            longitude = phi_j/scale if abs(phi_j) < abs(scale)*180 else 0
-            #print("(%+7.2f, %+7.2f) => (%+7.2f, %+7.2f)"%(theta_i, phi_j, latitude, longitude))
-            return Rx(longitude)*Ry(latitude)
-        def weight(theta_i, phi_j, wi, wj):
-            latitude = theta_i
-            scale = cos(radians(latitude))
-            w = 1 if abs(phi_j) < abs(scale)*180 else 0
-            return w*wi*wj
-    elif projection == 'guyou':  #define PROJECTION 3  (eventually?)
-        def rotate(theta_i, phi_j):
-            from guyou import guyou_invert
-            #latitude, longitude = guyou_invert([theta_i], [phi_j])
-            longitude, latitude = guyou_invert([phi_j], [theta_i])
-            return Rx(longitude[0])*Ry(latitude[0])
-        def weight(theta_i, phi_j, wi, wj):
-            return wi*wj
-    elif projection == 'azimuthal_equidistance':  # Note: Rz Ry, not Rx Ry
-        def rotate(theta_i, phi_j):
-            latitude = sqrt(theta_i**2 + phi_j**2)
-            longitude = degrees(np.arctan2(phi_j, theta_i))
-            #print("(%+7.2f, %+7.2f) => (%+7.2f, %+7.2f)"%(theta_i, phi_j, latitude, longitude))
-            return Rz(longitude)*Ry(latitude)
-        def weight(theta_i, phi_j, wi, wj):
-            # Weighting for each point comes from the integral:
-            #     \int\int I(q, lat, log) sin(lat) dlat dlog
-            # We are doing a conformal mapping from disk to sphere, so we need
-            # a change of variables g(theta, phi) -> (lat, long):
-            #     lat, long = sqrt(theta^2 + phi^2), arctan(phi/theta)
-            # giving:
-            #     dtheta dphi = det(J) dlat dlong
-            # where J is the jacobian from the partials of g. Using
-            #     R = sqrt(theta^2 + phi^2),
-            # then
-            #     J = [[x/R, Y/R], -y/R^2, x/R^2]]
-            # and
-            #     det(J) = 1/R
-            # with the final integral being:
-            #    \int\int I(q, theta, phi) sin(R)/R dtheta dphi
-            #
-            # This does approximately the right thing, decreasing the weight
-            # of each point as you go farther out on the disk, but it hasn't
-            # yet been checked against the 1D integral results. Prior
-            # to declaring this "good enough" and checking that integrals
-            # work in practice, we will examine alternative mappings.
-            #
-            # The issue is that the mapping does not support the case of free
-            # rotation about a single axis correctly, with a small deviation
-            # in the orthogonal axis independent of the first axis.  Like the
-            # usual polar coordiates integration, the integrated sections
-            # form wedges, though at least in this case the wedge cuts through
-            # the entire sphere, and treats theta and phi identically.
-            latitude = sqrt(theta_i**2 + phi_j**2)
-            w = sin(radians(latitude))/latitude if latitude != 0 else 1
-            return w*wi*wj if latitude < 180 else 0
-    elif projection == 'azimuthal_equal_area':
-        def rotate(theta_i, phi_j):
-            R = min(1, sqrt(theta_i**2 + phi_j**2)/180)
-            latitude = 180-degrees(2*np.arccos(R))
-            longitude = degrees(np.arctan2(phi_j, theta_i))
-            #print("(%+7.2f, %+7.2f) => (%+7.2f, %+7.2f)"%(theta_i, phi_j, latitude, longitude))
-            return Rz(longitude)*Ry(latitude)
-        def weight(theta_i, phi_j, wi, wj):
-            latitude = sqrt(theta_i**2 + phi_j**2)
-            w = sin(radians(latitude))/latitude if latitude != 0 else 1
-            return w*wi*wj if latitude < 180 else 0
-    else:
-        raise ValueError("unknown projection %r"%projection)
-
-    # mesh in theta, phi formed by rotating z
-    z = np.matrix([[0], [0], [radius]])
-    points = np.hstack([rotate(theta_i, phi_j)*z
-                        for theta_i in dtheta*t
-                        for phi_j in dphi*t])
-    # select just the active points (i.e., those with phi < 180
-    w = np.array([weight(theta_i, phi_j, wi, wj)
-                  for wi, theta_i in zip(weights, dtheta*t)
-                  for wj, phi_j in zip(weights, dphi*t)])
-    #print(max(w), min(w), min(w[w>0]))
-    points = points[:, w>0]
-    w = w[w>0]
-    w /= max(w)
-
-    if 0: # Kent distribution
-        points = np.hstack([Rx(phi_j)*Ry(theta_i)*z for theta_i in 30*t for phi_j in 60*t])
-        xp, yp, zp = [np.array(v).flatten() for v in points]
-        kappa = max(1e6, radians(dtheta)/(2*pi))
-        beta = 1/max(1e-6, radians(dphi)/(2*pi))/kappa
-        w = exp(kappa*zp) #+ beta*(xp**2 + yp**2)
-        print(kappa, dtheta, radians(dtheta), min(w), max(w), sum(w))
-        #w /= abs(cos(radians(
-        #w /= sum(w)
-
-    # rotate relative to beam
-    points = orient_relative_to_beam(view, points)
-
-    x, y, z = [np.array(v).flatten() for v in points]
-    #plt.figure(2); plt.clf(); plt.hist(z, bins=np.linspace(-1, 1, 51))
-    ax.scatter(x, y, z, c=w, marker='o', vmin=0., vmax=1.)
-
-def draw_labels(ax, view, jitter, text):
-    """
-    Draw text at a particular location.
-    """
-    labels, locations, orientations = zip(*text)
-    px, py, pz = zip(*locations)
-    dx, dy, dz = zip(*orientations)
-
-    px, py, pz = transform_xyz(view, jitter, px, py, pz)
-    dx, dy, dz = transform_xyz(view, jitter, dx, dy, dz)
-
-    # TODO: zdir for labels is broken, and labels aren't appearing.
-    for label, p, zdir in zip(labels, zip(px, py, pz), zip(dx, dy, dz)):
-        zdir = np.asarray(zdir).flatten()
-        ax.text(p[0], p[1], p[2], label, zdir=zdir)
-
-# Definition of rotation matrices comes from wikipedia:
-#    https://en.wikipedia.org/wiki/Rotation_matrix#Basic_rotations
-def Rx(angle):
-    """Construct a matrix to rotate points about *x* by *angle* degrees."""
-    a = radians(angle)
-    R = [[1, 0, 0],
-         [0, +cos(a), -sin(a)],
-         [0, +sin(a), +cos(a)]]
-    return np.matrix(R)
-
-def Ry(angle):
-    """Construct a matrix to rotate points about *y* by *angle* degrees."""
-    a = radians(angle)
-    R = [[+cos(a), 0, +sin(a)],
-         [0, 1, 0],
-         [-sin(a), 0, +cos(a)]]
-    return np.matrix(R)
-
-def Rz(angle):
-    """Construct a matrix to rotate points about *z* by *angle* degrees."""
-    a = radians(angle)
-    R = [[+cos(a), -sin(a), 0],
-         [+sin(a), +cos(a), 0],
-         [0, 0, 1]]
-    return np.matrix(R)
-
-def transform_xyz(view, jitter, x, y, z):
-    """
-    Send a set of (x,y,z) points through the jitter and view transforms.
-    """
-    x, y, z = [np.asarray(v) for v in (x, y, z)]
-    shape = x.shape
-    points = np.matrix([x.flatten(),y.flatten(),z.flatten()])
-    points = apply_jitter(jitter, points)
-    points = orient_relative_to_beam(view, points)
-    x, y, z = [np.array(v).reshape(shape) for v in points]
-    return x, y, z
-
-def apply_jitter(jitter, points):
-    """
-    Apply the jitter transform to a set of points.
-
-    Points are stored in a 3 x n numpy matrix, not a numpy array or tuple.
-    """
-    dtheta, dphi, dpsi = jitter
-    points = Rx(dphi)*Ry(dtheta)*Rz(dpsi)*points
-    return points
-
-def orient_relative_to_beam(view, points):
-    """
-    Apply the view transform to a set of points.
-
-    Points are stored in a 3 x n numpy matrix, not a numpy array or tuple.
-    """
-    theta, phi, psi = view
-    points = Rz(phi)*Ry(theta)*Rz(psi)*points
-    return points
-
-# translate between number of dimension of dispersity and the number of
-# points along each dimension.
-PD_N_TABLE = {
-    (0, 0, 0): (0, 0, 0),     # 0
-    (1, 0, 0): (100, 0, 0),   # 100
-    (0, 1, 0): (0, 100, 0),
-    (0, 0, 1): (0, 0, 100),
-    (1, 1, 0): (30, 30, 0),   # 900
-    (1, 0, 1): (30, 0, 30),
-    (0, 1, 1): (0, 30, 30),
-    (1, 1, 1): (15, 15, 15),  # 3375
-}
-
-def clipped_range(data, portion=1.0, mode='central'):
-    """
-    Determine range from data.
-
-    If *portion* is 1, use full range, otherwise use the center of the range
-    or the top of the range, depending on whether *mode* is 'central' or 'top'.
-    """
-    if portion == 1.0:
-        return data.min(), data.max()
-    elif mode == 'central':
-        data = np.sort(data.flatten())
-        offset = int(portion*len(data)/2 + 0.5)
-        return data[offset], data[-offset]
-    elif mode == 'top':
-        data = np.sort(data.flatten())
-        offset = int(portion*len(data) + 0.5)
-        return data[offset], data[-1]
-
-def draw_scattering(calculator, ax, view, jitter, dist='gaussian'):
-    """
-    Plot the scattering for the particular view.
-
-    *calculator* is returned from :func:`build_model`.  *ax* are the 3D axes
-    on which the data will be plotted.  *view* and *jitter* are the current
-    orientation and orientation dispersity.  *dist* is one of the sasmodels
-    weight distributions.
-    """
-    if dist == 'uniform':  # uniform is not yet in this branch
-        dist, scale = 'rectangle', 1/sqrt(3)
-    else:
-        scale = 1
-
-    # add the orientation parameters to the model parameters
-    theta, phi, psi = view
-    theta_pd, phi_pd, psi_pd = [scale*v for v in jitter]
-    theta_pd_n, phi_pd_n, psi_pd_n = PD_N_TABLE[(theta_pd>0, phi_pd>0, psi_pd>0)]
-    ## increase pd_n for testing jitter integration rather than simple viz
-    #theta_pd_n, phi_pd_n, psi_pd_n = [5*v for v in (theta_pd_n, phi_pd_n, psi_pd_n)]
-
-    pars = dict(
-        theta=theta, theta_pd=theta_pd, theta_pd_type=dist, theta_pd_n=theta_pd_n,
-        phi=phi, phi_pd=phi_pd, phi_pd_type=dist, phi_pd_n=phi_pd_n,
-        psi=psi, psi_pd=psi_pd, psi_pd_type=dist, psi_pd_n=psi_pd_n,
-    )
-    pars.update(calculator.pars)
-
-    # compute the pattern
-    qx, qy = calculator._data.x_bins, calculator._data.y_bins
-    Iqxy = calculator(**pars).reshape(len(qx), len(qy))
-
-    # scale it and draw it
-    Iqxy = np.log(Iqxy)
-    if calculator.limits:
-        # use limits from orientation (0,0,0)
-        vmin, vmax = calculator.limits
-    else:
-        vmax = Iqxy.max()
-        vmin = vmax*10**-7
-        #vmin, vmax = clipped_range(Iqxy, portion=portion, mode='top')
-    #print("range",(vmin,vmax))
-    #qx, qy = np.meshgrid(qx, qy)
-    if 0:
-        level = np.asarray(255*(Iqxy - vmin)/(vmax - vmin), 'i')
-        level[level<0] = 0
-        colors = plt.get_cmap()(level)
-        ax.plot_surface(qx, qy, -1.1, rstride=1, cstride=1, facecolors=colors)
-    elif 1:
-        ax.contourf(qx/qx.max(), qy/qy.max(), Iqxy, zdir='z', offset=-1.1,
-                    levels=np.linspace(vmin, vmax, 24))
-    else:
-        ax.pcolormesh(qx, qy, Iqxy)
-
-def build_model(model_name, n=150, qmax=0.5, **pars):
-    """
-    Build a calculator for the given shape.
-
-    *model_name* is any sasmodels model.  *n* and *qmax* define an n x n mesh
-    on which to evaluate the model.  The remaining parameters are stored in
-    the returned calculator as *calculator.pars*.  They are used by
-    :func:`draw_scattering` to set the non-orientation parameters in the
-    calculation.
-
-    Returns a *calculator* function which takes a dictionary or parameters and
-    produces Iqxy.  The Iqxy value needs to be reshaped to an n x n matrix
-    for plotting.  See the :class:`sasmodels.direct_model.DirectModel` class
-    for details.
-    """
-    from sasmodels.core import load_model_info, build_model
-    from sasmodels.data import empty_data2D
-    from sasmodels.direct_model import DirectModel
-
-    model_info = load_model_info(model_name)
-    model = build_model(model_info) #, dtype='double!')
-    q = np.linspace(-qmax, qmax, n)
-    data = empty_data2D(q, q)
-    calculator = DirectModel(data, model)
-
-    # stuff the values for non-orientation parameters into the calculator
-    calculator.pars = pars.copy()
-    calculator.pars.setdefault('backgound', 1e-3)
-
-    # fix the data limits so that we can see if the pattern fades
-    # under rotation or angular dispersion
-    Iqxy = calculator(theta=0, phi=0, psi=0, **calculator.pars)
-    Iqxy = np.log(Iqxy)
-    vmin, vmax = clipped_range(Iqxy, 0.95, mode='top')
-    calculator.limits = vmin, vmax+1
-
-    return calculator
-
-def select_calculator(model_name, n=150, size=(10,40,100)):
-    """
-    Create a model calculator for the given shape.
-
-    *model_name* is one of sphere, cylinder, ellipsoid, triaxial_ellipsoid,
-    parallelepiped or bcc_paracrystal. *n* is the number of points to use
-    in the q range.  *qmax* is chosen based on model parameters for the
-    given model to show something intersting.
-
-    Returns *calculator* and tuple *size* (a,b,c) giving minor and major
-    equitorial axes and polar axis respectively.  See :func:`build_model`
-    for details on the returned calculator.
-    """
-    a, b, c = size
-    if model_name == 'sphere':
-        calculator = build_model('sphere', n=n, radius=c)
-        a = b = c
-    elif model_name == 'bcc_paracrystal':
-        calculator = build_model('bcc_paracrystal', n=n, dnn=c,
-                                  d_factor=0.06, radius=40)
-        a = b = c
-    elif model_name == 'cylinder':
-        calculator = build_model('cylinder', n=n, qmax=0.3, radius=b, length=c)
-        a = b
-    elif model_name == 'ellipsoid':
-        calculator = build_model('ellipsoid', n=n, qmax=1.0,
-                                 radius_polar=c, radius_equatorial=b)
-        a = b
-    elif model_name == 'triaxial_ellipsoid':
-        calculator = build_model('triaxial_ellipsoid', n=n, qmax=0.5,
-                                 radius_equat_minor=a,
-                                 radius_equat_major=b,
-                                 radius_polar=c)
-    elif model_name == 'parallelepiped':
-        calculator = build_model('parallelepiped', n=n, a=a, b=b, c=c)
-    else:
-        raise ValueError("unknown model %s"%model_name)
-
-    return calculator, (a, b, c)
-
-SHAPES = [
-    'parallelepiped', 'triaxial_ellipsoid', 'bcc_paracrystal',
-    'cylinder', 'ellipsoid',
-    'sphere',
- ]
-
-DISTRIBUTIONS = [
-    'gaussian', 'rectangle', 'uniform',
-]
-DIST_LIMITS = {
-    'gaussian': 30,
-    'rectangle': 90/sqrt(3),
-    'uniform': 90,
-}
-
-def run(model_name='parallelepiped', size=(10, 40, 100),
-        dist='gaussian', mesh=30,
-        projection='equirectangular'):
-    """
-    Show an interactive orientation and jitter demo.
-
-    *model_name* is one of the models available in :func:`select_model`.
-    """
-    # projection number according to 1-order position in list, but
-    # only 1 and 2 are implemented so far.
-    from sasmodels import generate
-    generate.PROJECTION = PROJECTIONS.index(projection) + 1
-    if generate.PROJECTION > 2:
-        print("*** PROJECTION %s not implemented in scattering function ***"%projection)
-        generate.PROJECTION = 2
-
-    # set up calculator
-    calculator, size = select_calculator(model_name, n=150, size=size)
-
-    ## uncomment to set an independent the colour range for every view
-    ## If left commented, the colour range is fixed for all views
-    calculator.limits = None
-
-    ## initial view
-    #theta, dtheta = 70., 10.
-    #phi, dphi = -45., 3.
-    #psi, dpsi = -45., 3.
-    theta, phi, psi = 0, 0, 0
-    dtheta, dphi, dpsi = 0, 0, 0
-
-    ## create the plot window
-    #plt.hold(True)
-    plt.subplots(num=None, figsize=(5.5, 5.5))
-    plt.set_cmap('gist_earth')
-    plt.clf()
-    plt.gcf().canvas.set_window_title(projection)
-    #gs = gridspec.GridSpec(2,1,height_ratios=[4,1])
-    #ax = plt.subplot(gs[0], projection='3d')
-    ax = plt.axes([0.0, 0.2, 1.0, 0.8], projection='3d')
-    try:  # CRUFT: not all versions of matplotlib accept 'square' 3d projection
-        ax.axis('square')
-    except Exception:
-        pass
-
-    axcolor = 'lightgoldenrodyellow'
-
-    ## add control widgets to plot
-    axtheta  = plt.axes([0.1, 0.15, 0.45, 0.04], axisbg=axcolor)
-    axphi = plt.axes([0.1, 0.1, 0.45, 0.04], axisbg=axcolor)
-    axpsi = plt.axes([0.1, 0.05, 0.45, 0.04], axisbg=axcolor)
-    stheta = Slider(axtheta, 'Theta', -90, 90, valinit=theta)
-    sphi = Slider(axphi, 'Phi', -180, 180, valinit=phi)
-    spsi = Slider(axpsi, 'Psi', -180, 180, valinit=psi)
-
-    axdtheta  = plt.axes([0.75, 0.15, 0.15, 0.04], axisbg=axcolor)
-    axdphi = plt.axes([0.75, 0.1, 0.15, 0.04], axisbg=axcolor)
-    axdpsi= plt.axes([0.75, 0.05, 0.15, 0.04], axisbg=axcolor)
-    # Note: using ridiculous definition of rectangle distribution, whose width
-    # in sasmodels is sqrt(3) times the given width.  Divide by sqrt(3) to keep
-    # the maximum width to 90.
-    dlimit = DIST_LIMITS[dist]
-    sdtheta = Slider(axdtheta, 'dTheta', 0, 2*dlimit, valinit=dtheta)
-    sdphi = Slider(axdphi, 'dPhi', 0, 2*dlimit, valinit=dphi)
-    sdpsi = Slider(axdpsi, 'dPsi', 0, 2*dlimit, valinit=dpsi)
-
-    ## callback to draw the new view
-    def update(val, axis=None):
-        view = stheta.val, sphi.val, spsi.val
-        jitter = sdtheta.val, sdphi.val, sdpsi.val
-        # set small jitter as 0 if multiple pd dims
-        dims = sum(v > 0 for v in jitter)
-        limit = [0, 0, 0.5, 5][dims]
-        jitter = [0 if v < limit else v for v in jitter]
-        ax.cla()
-        draw_beam(ax, (0, 0))
-        draw_jitter(ax, view, jitter, dist=dist, size=size)
-        #draw_jitter(ax, view, (0,0,0))
-        draw_mesh(ax, view, jitter, dist=dist, n=mesh, projection=projection)
-        draw_scattering(calculator, ax, view, jitter, dist=dist)
-        plt.gcf().canvas.draw()
-
-    ## bind control widgets to view updater
-    stheta.on_changed(lambda v: update(v,'theta'))
-    sphi.on_changed(lambda v: update(v, 'phi'))
-    spsi.on_changed(lambda v: update(v, 'psi'))
-    sdtheta.on_changed(lambda v: update(v, 'dtheta'))
-    sdphi.on_changed(lambda v: update(v, 'dphi'))
-    sdpsi.on_changed(lambda v: update(v, 'dpsi'))
-
-    ## initialize view
-    update(None, 'phi')
-
-    ## go interactive
-    plt.show()
-
-def main():
-    parser = argparse.ArgumentParser(
-        description="Display jitter",
-        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
-        )
-    parser.add_argument('-p', '--projection', choices=PROJECTIONS, default=PROJECTIONS[0], help='coordinate projection')
-    parser.add_argument('-s', '--size', type=str, default='10,40,100', help='a,b,c lengths')
-    parser.add_argument('-d', '--distribution', choices=DISTRIBUTIONS, default=DISTRIBUTIONS[0], help='jitter distribution')
-    parser.add_argument('-m', '--mesh', type=int, default=30, help='#points in theta-phi mesh')
-    parser.add_argument('shape', choices=SHAPES, nargs='?', default=SHAPES[0], help='oriented shape')
-    opts = parser.parse_args()
-    size = tuple(int(v) for v in opts.size.split(','))
-    run(opts.shape, size=size,
-        mesh=opts.mesh, dist=opts.distribution,
-        projection=opts.projection)
-
-if __name__ == "__main__":
-    main()
Index: explore/realspace.py
===================================================================
--- explore/realspace.py	(revision 8fb2a944dfdac4468e098855656ee605177840c0)
+++ explore/realspace.py	(revision 362a66f0d054d3213d4e56147f9378d15b5616f0)
@@ -3,11 +3,20 @@
 import time
 from copy import copy
+import os
+import argparse
+from collections import OrderedDict
 
 import numpy as np
 from numpy import pi, radians, sin, cos, sqrt
-from numpy.random import poisson, uniform
+from numpy.random import poisson, uniform, randn, rand
 from numpy.polynomial.legendre import leggauss
 from scipy.integrate import simps
 from scipy.special import j1 as J1
+
+try:
+    import numba
+    USE_NUMBA = True
+except ImportError:
+    USE_NUMBA = False
 
 # Definition of rotation matrices comes from wikipedia:
@@ -82,4 +91,8 @@
         raise NotImplementedError()
 
+    def dims(self):
+        # type: () -> float, float, float
+        raise NotImplementedError()
+
     def rotate(self, theta, phi, psi):
         self.rotation = rotation(theta, phi, psi) * self.rotation
@@ -116,7 +129,5 @@
                      for s2 in shapes]
         self.r_max = max(distances + [s.r_max for s in shapes])
-
-    def volume(self):
-        return sum(shape.volume() for shape in self.shapes)
+        self.volume = sum(shape.volume for shape in self.shapes)
 
     def sample(self, density):
@@ -133,10 +144,9 @@
         self._scale = np.array([a/2, b/2, c/2])[None, :]
         self.r_max = sqrt(a**2 + b**2 + c**2)
-
-    def volume(self):
-        return self.a*self.b*self.c
+        self.dims = a, b, c
+        self.volume = a*b*c
 
     def sample(self, density):
-        num_points = poisson(density*self.a*self.b*self.c)
+        num_points = poisson(density*self.volume)
         points = self._scale*uniform(-1, 1, size=(num_points, 3))
         values = self.value.repeat(points.shape[0])
@@ -152,16 +162,57 @@
         self._scale = np.array([ra, rb, length/2])[None, :]
         self.r_max = sqrt(4*max(ra, rb)**2 + length**2)
-
-    def volume(self):
-        return pi*self.ra*self.rb*self.length
+        self.dims = 2*ra, 2*rb, length
+        self.volume = pi*ra*rb*length
 
     def sample(self, density):
-        # density of the bounding box
+        # randomly sample from a box of side length 2*r, excluding anything
+        # not in the cylinder
         num_points = poisson(density*4*self.ra*self.rb*self.length)
         points = uniform(-1, 1, size=(num_points, 3))
         radius = points[:, 0]**2 + points[:, 1]**2
-        points = self._scale*points[radius <= 1]
+        points = points[radius <= 1]
         values = self.value.repeat(points.shape[0])
-        return values, self._adjust(points)
+        return values, self._adjust(self._scale*points)
+
+class EllipticalBicelle(Shape):
+    def __init__(self, ra, rb, length,
+                 thick_rim, thick_face,
+                 value_core, value_rim, value_face,
+                 center=(0, 0, 0), orientation=(0, 0, 0)):
+        self.rotate(*orientation)
+        self.shift(*center)
+        self.value = value_core
+        self.ra, self.rb, self.length = ra, rb, length
+        self.thick_rim, self.thick_face = thick_rim, thick_face
+        self.value_rim, self.value_face = value_rim, value_face
+
+        # reset cylinder to outer dimensions for calculating scale, etc.
+        ra = self.ra + self.thick_rim
+        rb = self.rb + self.thick_rim
+        length = self.length + 2*self.thick_face
+        self._scale = np.array([ra, rb, length/2])[None, :]
+        self.r_max = sqrt(4*max(ra, rb)**2 + length**2)
+        self.dims = 2*ra, 2*rb, length
+        self.volume = pi*ra*rb*length
+
+    def sample(self, density):
+        # randomly sample from a box of side length 2*r, excluding anything
+        # not in the cylinder
+        ra = self.ra + self.thick_rim
+        rb = self.rb + self.thick_rim
+        length = self.length + 2*self.thick_face
+        num_points = poisson(density*4*ra*rb*length)
+        points = uniform(-1, 1, size=(num_points, 3))
+        radius = points[:, 0]**2 + points[:, 1]**2
+        points = points[radius <= 1]
+        # set all to core value first
+        values = np.ones_like(points[:, 0])*self.value
+        # then set value to face value if |z| > face/(length/2))
+        values[abs(points[:, 2]) > self.length/(self.length + 2*self.thick_face)] = self.value_face
+        # finally set value to rim value if outside the core ellipse
+        radius = (points[:, 0]**2*(1 + self.thick_rim/self.ra)**2
+                  + points[:, 1]**2*(1 + self.thick_rim/self.rb)**2)
+        values[radius>1] = self.value_rim
+        return values, self._adjust(self._scale*points)
 
 class TriaxialEllipsoid(Shape):
@@ -174,7 +225,6 @@
         self._scale = np.array([ra, rb, rc])[None, :]
         self.r_max = 2*max(ra, rb, rc)
-
-    def volume(self):
-        return 4*pi/3 * self.ra * self.rb * self.rc
+        self.dims = 2*ra, 2*rb, 2*rc
+        self.volume = 4*pi/3 * ra * rb * rc
 
     def sample(self, density):
@@ -194,15 +244,14 @@
         self.rotate(*orientation)
         self.shift(*center)
+        helix_length = helix_pitch * tube_length/sqrt(helix_radius**2 + helix_pitch**2)
+        total_radius = self.helix_radius + self.tube_radius
         self.helix_radius, self.helix_pitch = helix_radius, helix_pitch
         self.tube_radius, self.tube_length = tube_radius, tube_length
-        helix_length = helix_pitch * tube_length/sqrt(helix_radius**2 + helix_pitch**2)
-        self.r_max = sqrt((2*helix_radius + 2*tube_radius)*2
-                          + (helix_length + 2*tube_radius)**2)
-
-    def volume(self):
+        self.r_max = sqrt(4*total_radius + (helix_length + 2*tube_radius)**2)
+        self.dims = 2*total_radius, 2*total_radius, helix_length
         # small tube radius approximation; for larger tubes need to account
         # for the fact that the inner length is much shorter than the outer
         # length
-        return pi*self.tube_radius**2*self.tube_length
+        self.volume = pi*self.tube_radius**2*self.tube_length
 
     def points(self, density):
@@ -244,6 +293,24 @@
         return values, self._adjust(points)
 
-NUMBA = False
-if NUMBA:
+def csbox(a=10, b=20, c=30, da=1, db=2, dc=3, slda=1, sldb=2, sldc=3, sld_core=4):
+    core = Box(a, b, c, sld_core)
+    side_a = Box(da, b, c, slda, center=((a+da)/2, 0, 0))
+    side_b = Box(a, db, c, sldb, center=(0, (b+db)/2, 0))
+    side_c = Box(a, b, dc, sldc, center=(0, 0, (c+dc)/2))
+    side_a2 = copy(side_a).shift(-a-da, 0, 0)
+    side_b2 = copy(side_b).shift(0, -b-db, 0)
+    side_c2 = copy(side_c).shift(0, 0, -c-dc)
+    shape = Composite((core, side_a, side_b, side_c, side_a2, side_b2, side_c2))
+    shape.dims = 2*da+a, 2*db+b, 2*dc+c
+    return shape
+
+def _Iqxy(values, x, y, z, qa, qb, qc):
+    """I(q) = |sum V(r) rho(r) e^(1j q.r)|^2 / sum V(r)"""
+    Iq = [abs(np.sum(values*np.exp(1j*(qa_k*x + qb_k*y + qc_k*z))))**2
+            for qa_k, qb_k, qc_k in zip(qa.flat, qb.flat, qc.flat)]
+    return Iq
+
+if USE_NUMBA:
+    # Override simple numpy solution with numba if available
     from numba import njit
     @njit("f8[:](f8[:],f8[:],f8[:],f8[:],f8[:],f8[:],f8[:])")
@@ -252,5 +319,5 @@
         for j in range(len(Iq)):
             total = 0. + 0j
-            for k in range(len(Iq)):
+            for k in range(len(values)):
                 total += values[k]*np.exp(1j*(qa[j]*x[k] + qb[j]*y[k] + qc[j]*z[k]))
             Iq[j] = abs(total)**2
@@ -263,11 +330,9 @@
     values = rho*volume
     x, y, z = points.T
+    values, x, y, z, qa, qb, qc = [np.asarray(v, 'd')
+                                   for v in (values, x, y, z, qa, qb, qc)]
 
     # I(q) = |sum V(r) rho(r) e^(1j q.r)|^2 / sum V(r)
-    if NUMBA:
-        Iq = _Iqxy(values, x, y, z, qa.flatten(), qb.flatten(), qc.flatten())
-    else:
-        Iq = [abs(np.sum(values*np.exp(1j*(qa_k*x + qb_k*y + qc_k*z))))**2
-              for qa_k, qb_k, qc_k in zip(qa.flat, qb.flat, qc.flat)]
+    Iq = _Iqxy(values, x, y, z, qa.flatten(), qb.flatten(), qc.flatten())
     return np.asarray(Iq).reshape(qx.shape) / np.sum(volume)
 
@@ -338,7 +403,8 @@
 """
 
-if NUMBA:
+if USE_NUMBA:
+    # Override simple numpy solution with numba if available
     @njit("f8[:](f8[:], f8[:], f8[:,:])")
-    def _calc_Pr_uniform_jit(r, rho, points):
+    def _calc_Pr_uniform(r, rho, points):
         dr = r[0]
         n_max = len(r)
@@ -362,11 +428,9 @@
     # P(r) with uniform steps in r is 3x faster; check if we are uniform
     # before continuing
+    r, rho, points = [np.asarray(v, 'd') for v in (r, rho, points)]
     if np.max(np.abs(np.diff(r) - r[0])) > r[0]*0.01:
         Pr = _calc_Pr_nonuniform(r, rho, points)
     else:
-        if NUMBA:
-            Pr = _calc_Pr_uniform_jit(r, rho, points)
-        else:
-            Pr = _calc_Pr_uniform(r, rho, points)
+        Pr = _calc_Pr_uniform(r, rho, points)
     return Pr / Pr.max()
 
@@ -399,5 +463,6 @@
     return edges
 
-def plot_calc(r, Pr, q, Iq, theory=None):
+# -------------- plotters ----------------
+def plot_calc(r, Pr, q, Iq, theory=None, title=None):
     import matplotlib.pyplot as plt
     plt.subplot(211)
@@ -405,4 +470,6 @@
     plt.xlabel('r (A)')
     plt.ylabel('Pr (1/A^2)')
+    if title is not None:
+        plt.title(title)
     plt.subplot(212)
     plt.loglog(q, Iq, '-', label='from Pr')
@@ -410,8 +477,8 @@
     plt.ylabel('Iq')
     if theory is not None:
-        plt.loglog(theory[0], theory[1], '-', label='analytic')
+        plt.loglog(theory[0], theory[1]/theory[1][0], '-', label='analytic')
         plt.legend()
 
-def plot_calc_2d(qx, qy, Iqxy, theory=None):
+def plot_calc_2d(qx, qy, Iqxy, theory=None, title=None):
     import matplotlib.pyplot as plt
     qx, qy = bin_edges(qx), bin_edges(qy)
@@ -419,10 +486,21 @@
     if theory is not None:
         plt.subplot(121)
-    plt.pcolormesh(qx, qy, np.log10(Iqxy))
+    #plt.pcolor(qx, qy, np.log10(Iqxy))
+    extent = [qx[0], qx[-1], qy[0], qy[-1]]
+    plt.imshow(np.log10(Iqxy), extent=extent, interpolation="nearest",
+               origin='lower')
     plt.xlabel('qx (1/A)')
     plt.ylabel('qy (1/A)')
+    plt.axis('equal')
+    plt.axis(extent)
+    #plt.grid(True)
+    if title is not None:
+        plt.title(title)
     if theory is not None:
         plt.subplot(122)
-        plt.pcolormesh(qx, qy, np.log10(theory))
+        plt.imshow(np.log10(theory), extent=extent, interpolation="nearest",
+                   origin='lower')
+        plt.axis('equal')
+        plt.axis(extent)
         plt.xlabel('qx (1/A)')
 
@@ -440,48 +518,15 @@
     index = np.random.choice(n, size=500) if n > 500 else slice(None, None)
     ax.scatter(points[index, 0], points[index, 1], points[index, 2], c=rho[index])
+    # make square axes
+    minmax = np.array([points.min(), points.max()])
+    ax.scatter(minmax, minmax, minmax, c='w')
     #low, high = points.min(axis=0), points.max(axis=0)
     #ax.axis([low[0], high[0], low[1], high[1], low[2], high[2]])
+    ax.set_xlabel("x")
+    ax.set_ylabel("y")
+    ax.set_zlabel("z")
     ax.autoscale(True)
 
-def check_shape(shape, fn=None):
-    rho_solvent = 0
-    q = np.logspace(-3, 0, 200)
-    r = shape.r_bins(q, r_step=0.01)
-    sampling_density = 6*5000 / shape.volume()
-    rho, points = shape.sample(sampling_density)
-    t0 = time.time()
-    Pr = calc_Pr(r, rho-rho_solvent, points)
-    print("calc Pr time", time.time() - t0)
-    Iq = calc_Iq(q, r, Pr)
-    theory = (q, fn(q)) if fn is not None else None
-
-    import pylab
-    #plot_points(rho, points); pylab.figure()
-    plot_calc(r, Pr, q, Iq, theory=theory)
-    pylab.show()
-
-def check_shape_2d(shape, fn=None, view=(0, 0, 0)):
-    rho_solvent = 0
-    nq, qmax = 100, 1.0
-    qx = np.linspace(0.0, qmax, nq)
-    qy = np.linspace(0.0, qmax, nq)
-    Qx, Qy = np.meshgrid(qx, qy)
-    sampling_density = 50000 / shape.volume()
-    #t0 = time.time()
-    rho, points = shape.sample(sampling_density)
-    #print("sample time", time.time() - t0)
-    t0 = time.time()
-    Iqxy = calc_Iqxy(Qx, Qy, rho, points, view=view)
-    print("calc time", time.time() - t0)
-    theory = fn(Qx, Qy) if fn is not None else None
-    Iqxy += 0.001 * Iqxy.max()
-    if theory is not None:
-        theory += 0.001 * theory.max()
-
-    import pylab
-    #plot_points(rho, points); pylab.figure()
-    plot_calc_2d(qx, qy, Iqxy, theory=theory)
-    pylab.show()
-
+# ----------- Analytic models --------------
 def sas_sinx_x(x):
     with np.errstate(all='ignore'):
@@ -510,13 +555,13 @@
     for k, qk in enumerate(q):
         qab, qc = qk*sin_alpha, qk*cos_alpha
-        Fq = sas_2J1x_x(qab*radius) * j0(qc*length/2)
+        Fq = sas_2J1x_x(qab*radius) * sas_sinx_x(qc*length/2)
         Iq[k] = np.sum(w*Fq**2)
-    Iq = Iq/Iq[0]
+    Iq = Iq
     return Iq
 
 def cylinder_Iqxy(qx, qy, radius, length, view=(0, 0, 0)):
     qa, qb, qc = invert_view(qx, qy, view)
-    qab = np.sqrt(qa**2 + qb**2)
-    Fq = sas_2J1x_x(qab*radius) * j0(qc*length/2)
+    qab = sqrt(qa**2 + qb**2)
+    Fq = sas_2J1x_x(qab*radius) * sas_sinx_x(qc*length/2)
     Iq = Fq**2
     return Iq.reshape(qx.shape)
@@ -524,5 +569,32 @@
 def sphere_Iq(q, radius):
     Iq = sas_3j1x_x(q*radius)**2
-    return Iq/Iq[0]
+    return Iq
+
+def box_Iq(q, a, b, c):
+    z, w = leggauss(76)
+    outer_sum = np.zeros_like(q)
+    for cos_alpha, outer_w in zip((z+1)/2, w):
+        sin_alpha = sqrt(1.0-cos_alpha*cos_alpha)
+        qc = q*cos_alpha
+        siC = c*sas_sinx_x(c*qc/2)
+        inner_sum = np.zeros_like(q)
+        for beta, inner_w in zip((z + 1)*pi/4, w):
+            qa, qb = q*sin_alpha*sin(beta), q*sin_alpha*cos(beta)
+            siA = a*sas_sinx_x(a*qa/2)
+            siB = b*sas_sinx_x(b*qb/2)
+            Fq = siA*siB*siC
+            inner_sum += inner_w * Fq**2
+        outer_sum += outer_w * inner_sum
+    Iq = outer_sum / 4  # = outer*um*zm*8.0/(4.0*M_PI)
+    return Iq
+
+def box_Iqxy(qx, qy, a, b, c, view=(0, 0, 0)):
+    qa, qb, qc = invert_view(qx, qy, view)
+    sia = sas_sinx_x(qa*a/2)
+    sib = sas_sinx_x(qb*b/2)
+    sic = sas_sinx_x(qc*c/2)
+    Fq = sia*sib*sic
+    Iq = Fq**2
+    return Iq.reshape(qx.shape)
 
 def csbox_Iq(q, a, b, c, da, db, dc, slda, sldb, sldc, sld_core):
@@ -539,13 +611,13 @@
         sin_alpha = sqrt(1.0-cos_alpha*cos_alpha)
         qc = q*cos_alpha
-        siC = c*j0(c*qc/2)
-        siCt = tC*j0(tC*qc/2)
+        siC = c*sas_sinx_x(c*qc/2)
+        siCt = tC*sas_sinx_x(tC*qc/2)
         inner_sum = np.zeros_like(q)
         for beta, inner_w in zip((z + 1)*pi/4, w):
             qa, qb = q*sin_alpha*sin(beta), q*sin_alpha*cos(beta)
-            siA = a*j0(a*qa/2)
-            siB = b*j0(b*qb/2)
-            siAt = tA*j0(tA*qa/2)
-            siBt = tB*j0(tB*qb/2)
+            siA = a*sas_sinx_x(a*qa/2)
+            siB = b*sas_sinx_x(b*qb/2)
+            siAt = tA*sas_sinx_x(tA*qa/2)
+            siBt = tB*sas_sinx_x(tB*qb/2)
             if overlapping:
                 Fq = (dr0*siA*siB*siC
@@ -584,65 +656,220 @@
     return Iq.reshape(qx.shape)
 
-def check_cylinder(radius=25, length=125, rho=2.):
+def sasmodels_Iq(kernel, q, pars):
+    from sasmodels.data import empty_data1D
+    from sasmodels.direct_model import DirectModel
+    data = empty_data1D(q)
+    calculator = DirectModel(data, kernel)
+    Iq = calculator(**pars)
+    return Iq
+
+def sasmodels_Iqxy(kernel, qx, qy, pars, view):
+    from sasmodels.data import Data2D
+    from sasmodels.direct_model import DirectModel
+    Iq = 100 * np.ones_like(qx)
+    data = Data2D(x=qx, y=qy, z=Iq, dx=None, dy=None, dz=np.sqrt(Iq))
+    data.x_bins = qx[0,:]
+    data.y_bins = qy[:,0]
+    data.filename = "fake data"
+
+    calculator = DirectModel(data, kernel)
+    pars_plus_view = pars.copy()
+    pars_plus_view.update(theta=view[0], phi=view[1], psi=view[2])
+    Iqxy = calculator(**pars_plus_view)
+    return Iqxy.reshape(qx.shape)
+
+def wrap_sasmodel(name, **pars):
+    from sasmodels.core import load_model
+    kernel = load_model(name)
+    fn = lambda q: sasmodels_Iq(kernel, q, pars)
+    fn_xy = lambda qx, qy, view: sasmodels_Iqxy(kernel, qx, qy, pars, view)
+    return fn, fn_xy
+
+
+# --------- Test cases -----------
+
+def build_cylinder(radius=25, length=125, rho=2.):
     shape = EllipticalCylinder(radius, radius, length, rho)
-    fn = lambda q: cylinder_Iq(q, radius, length)
-    check_shape(shape, fn)
-
-def check_cylinder_2d(radius=25, length=125, rho=2., view=(0, 0, 0)):
-    shape = EllipticalCylinder(radius, radius, length, rho)
-    fn = lambda qx, qy, view=view: cylinder_Iqxy(qx, qy, radius, length, view=view)
-    check_shape_2d(shape, fn, view=view)
-
-def check_cylinder_2d_lattice(radius=25, length=125, rho=2.,
-                              view=(0, 0, 0)):
-    nx, dx = 1, 2*radius
-    ny, dy = 30, 2*radius
-    nz, dz = 30, length
-    dx, dy, dz = 2*dx, 2*dy, 2*dz
-    def center(*args):
-        sigma = 0.333
-        space = 2
-        return [(space*n+np.random.randn()*sigma)*x for n, x in args]
-    shapes = [EllipticalCylinder(radius, radius, length, rho,
-                                 #center=(ix*dx, iy*dy, iz*dz)
-                                 orientation=np.random.randn(3)*0,
-                                 center=center((ix, dx), (iy, dy), (iz, dz))
-                                )
+    fn = lambda q: cylinder_Iq(q, radius, length)*rho**2
+    fn_xy = lambda qx, qy, view: cylinder_Iqxy(qx, qy, radius, length, view=view)*rho**2
+    return shape, fn, fn_xy
+
+def build_sphere(radius=125, rho=2):
+    shape = TriaxialEllipsoid(radius, radius, radius, rho)
+    fn = lambda q: sphere_Iq(q, radius)*rho**2
+    fn_xy = lambda qx, qy, view: sphere_Iq(np.sqrt(qx**2+qy**2), radius)*rho**2
+    return shape, fn, fn_xy
+
+def build_box(a=10, b=20, c=30, rho=2.):
+    shape = Box(a, b, c, rho)
+    fn = lambda q: box_Iq(q, a, b, c)*rho**2
+    fn_xy = lambda qx, qy, view: box_Iqxy(qx, qy, a, b, c, view=view)*rho**2
+    return shape, fn, fn_xy
+
+def build_csbox(a=10, b=20, c=30, da=1, db=2, dc=3, slda=1, sldb=2, sldc=3, sld_core=4):
+    shape = csbox(a, b, c, da, db, dc, slda, sldb, sldc, sld_core)
+    fn = lambda q: csbox_Iq(q, a, b, c, da, db, dc, slda, sldb, sldc, sld_core)
+    fn_xy = lambda qx, qy, view: csbox_Iqxy(qx, qy, a, b, c, da, db, dc,
+                                            slda, sldb, sldc, sld_core, view=view)
+    return shape, fn, fn_xy
+
+def build_ellcyl(ra=25, rb=50, length=125, rho=2.):
+    shape = EllipticalCylinder(ra, rb, length, rho)
+    fn, fn_xy = wrap_sasmodel(
+        'elliptical_cylinder',
+        scale=1,
+        background=0,
+        radius_minor=ra,
+        axis_ratio=rb/ra,
+        length=length,
+        sld=rho,
+        sld_solvent=0,
+    )
+    return shape, fn, fn_xy
+
+def build_cscyl(ra=30, rb=90, length=30, thick_rim=8, thick_face=14,
+                sld_core=4, sld_rim=1, sld_face=7):
+    shape = EllipticalBicelle(
+        ra=ra, rb=rb, length=length,
+        thick_rim=thick_rim, thick_face=thick_face,
+        value_core=sld_core, value_rim=sld_rim, value_face=sld_face,
+        )
+    fn, fn_xy = wrap_sasmodel(
+        'core_shell_bicelle_elliptical',
+        scale=1,
+        background=0,
+        radius=ra,
+        x_core=rb/ra,
+        length=length,
+        thick_rim=thick_rim,
+        thick_face=thick_face,
+        sld_core=sld_core,
+        sld_face=sld_face,
+        sld_rim=sld_rim,
+        sld_solvent=0,
+    )
+    return shape, fn, fn_xy
+
+def build_cubic_lattice(shape, nx=1, ny=1, nz=1, dx=2, dy=2, dz=2,
+                  shuffle=0, rotate=0):
+    a, b, c = shape.dims
+    shapes = [copy(shape)
+              .shift((ix+(randn() if shuffle < 0.3 else rand())*shuffle)*dx*a,
+                     (iy+(randn() if shuffle < 0.3 else rand())*shuffle)*dy*b,
+                     (iz+(randn() if shuffle < 0.3 else rand())*shuffle)*dz*c)
+              .rotate(*((randn(3) if rotate < 30 else rand(3))*rotate))
               for ix in range(nx)
               for iy in range(ny)
               for iz in range(nz)]
-    shape = Composite(shapes)
-    fn = lambda qx, qy, view=view: cylinder_Iqxy(qx, qy, radius, length, view=view)
-    check_shape_2d(shape, fn, view=view)
-
-def check_sphere(radius=125, rho=2):
-    shape = TriaxialEllipsoid(radius, radius, radius, rho)
-    fn = lambda q: sphere_Iq(q, radius)
-    check_shape(shape, fn)
-
-def check_csbox(a=10, b=20, c=30, da=1, db=2, dc=3, slda=1, sldb=2, sldc=3, sld_core=4):
-    core = Box(a, b, c, sld_core)
-    side_a = Box(da, b, c, slda, center=((a+da)/2, 0, 0))
-    side_b = Box(a, db, c, sldb, center=(0, (b+db)/2, 0))
-    side_c = Box(a, b, dc, sldc, center=(0, 0, (c+dc)/2))
-    side_a2 = copy(side_a).shift(-a-da, 0, 0)
-    side_b2 = copy(side_b).shift(0, -b-db, 0)
-    side_c2 = copy(side_c).shift(0, 0, -c-dc)
-    shape = Composite((core, side_a, side_b, side_c, side_a2, side_b2, side_c2))
-    def fn(q):
-        return csbox_Iq(q, a, b, c, da, db, dc, slda, sldb, sldc, sld_core)
-    #check_shape(shape, fn)
-
-    view = (20, 30, 40)
-    def fn_xy(qx, qy):
-        return csbox_Iqxy(qx, qy, a, b, c, da, db, dc,
-                          slda, sldb, sldc, sld_core, view=view)
-    check_shape_2d(shape, fn_xy, view=view)
+    lattice = Composite(shapes)
+    return lattice
+
+
+SHAPE_FUNCTIONS = OrderedDict([
+    ("cyl", build_cylinder),
+    ("ellcyl", build_ellcyl),
+    ("sphere", build_sphere),
+    ("box", build_box),
+    ("csbox", build_csbox),
+    ("cscyl", build_cscyl),
+])
+SHAPES = list(SHAPE_FUNCTIONS.keys())
+
+def check_shape(title, shape, fn=None, show_points=False,
+                mesh=100, qmax=1.0, r_step=0.01, samples=5000):
+    rho_solvent = 0
+    qmin = qmax/100.
+    q = np.logspace(np.log10(qmin), np.log10(qmax), mesh)
+    r = shape.r_bins(q, r_step=r_step)
+    sampling_density = samples / shape.volume
+    rho, points = shape.sample(sampling_density)
+    t0 = time.time()
+    Pr = calc_Pr(r, rho-rho_solvent, points)
+    print("calc Pr time", time.time() - t0)
+    Iq = calc_Iq(q, r, Pr)
+    theory = (q, fn(q)) if fn is not None else None
+
+    import pylab
+    if show_points:
+         plot_points(rho, points); pylab.figure()
+    plot_calc(r, Pr, q, Iq, theory=theory, title=title)
+    pylab.gcf().canvas.set_window_title(title)
+    pylab.show()
+
+def check_shape_2d(title, shape, fn=None, view=(0, 0, 0), show_points=False,
+                   mesh=100, qmax=1.0, samples=5000):
+    rho_solvent = 0
+    #qx = np.linspace(0.0, qmax, mesh)
+    #qy = np.linspace(0.0, qmax, mesh)
+    qx = np.linspace(-qmax, qmax, mesh)
+    qy = np.linspace(-qmax, qmax, mesh)
+    Qx, Qy = np.meshgrid(qx, qy)
+    sampling_density = samples / shape.volume
+    t0 = time.time()
+    rho, points = shape.sample(sampling_density)
+    print("point generation time", time.time() - t0)
+    t0 = time.time()
+    Iqxy = calc_Iqxy(Qx, Qy, rho, points, view=view)
+    print("calc Iqxy time", time.time() - t0)
+    t0 = time.time()
+    theory = fn(Qx, Qy, view) if fn is not None else None
+    print("calc theory time", time.time() - t0)
+    Iqxy += 0.001 * Iqxy.max()
+    if theory is not None:
+        theory += 0.001 * theory.max()
+
+    import pylab
+    if show_points:
+        plot_points(rho, points); pylab.figure()
+    plot_calc_2d(qx, qy, Iqxy, theory=theory, title=title)
+    pylab.gcf().canvas.set_window_title(title)
+    pylab.show()
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Compute scattering from realspace sampling",
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+        )
+    parser.add_argument('-d', '--dim', type=int, default=1,
+                        help='dimension 1 or 2')
+    parser.add_argument('-m', '--mesh', type=int, default=100,
+                        help='number of mesh points')
+    parser.add_argument('-s', '--samples', type=int, default=5000,
+                        help="number of sample points")
+    parser.add_argument('-q', '--qmax', type=float, default=0.5,
+                        help='max q')
+    parser.add_argument('-v', '--view', type=str, default='0,0,0',
+                        help='theta,phi,psi angles')
+    parser.add_argument('-n', '--lattice', type=str, default='1,1,1',
+                        help='lattice size')
+    parser.add_argument('-z', '--spacing', type=str, default='2,2,2',
+                        help='lattice spacing')
+    parser.add_argument('-r', '--rotate', type=float, default=0.,
+                        help="rotation relative to lattice, gaussian < 30 degrees, uniform otherwise")
+    parser.add_argument('-w', '--shuffle', type=float, default=0.,
+                        help="position relative to lattice, gaussian < 0.3, uniform otherwise")
+    parser.add_argument('-p', '--plot', action='store_true',
+                        help='plot points')
+    parser.add_argument('shape', choices=SHAPES, nargs='?', default=SHAPES[0],
+                        help='oriented shape')
+    parser.add_argument('pars', type=str, nargs='*', help='shape parameters')
+    opts = parser.parse_args()
+    pars = {key: float(value) for p in opts.pars for key, value in [p.split('=')]}
+    nx, ny, nz = [int(v) for v in opts.lattice.split(',')]
+    dx, dy, dz = [float(v) for v in opts.spacing.split(',')]
+    shuffle, rotate = opts.shuffle, opts.rotate
+    shape, fn, fn_xy = SHAPE_FUNCTIONS[opts.shape](**pars)
+    if nx > 1 or ny > 1 or nz > 1:
+        shape = build_cubic_lattice(shape, nx, ny, nz, dx, dy, dz, shuffle, rotate)
+    title = "%s(%s)" % (opts.shape, " ".join(opts.pars))
+    if opts.dim == 1:
+        check_shape(title, shape, fn, show_points=opts.plot,
+                    mesh=opts.mesh, qmax=opts.qmax, samples=opts.samples)
+    else:
+        view = tuple(float(v) for v in opts.view.split(','))
+        check_shape_2d(title, shape, fn_xy, view=view, show_points=opts.plot,
+                       mesh=opts.mesh, qmax=opts.qmax, samples=opts.samples)
+
 
 if __name__ == "__main__":
-    check_cylinder(radius=10, length=40)
-    #check_cylinder_2d(radius=10, length=40, view=(90,30,0))
-    #check_cylinder_2d_lattice(radius=10, length=50, view=(90,30,0))
-    #check_sphere()
-    #check_csbox()
-    #check_csbox(da=100, db=200, dc=300)
+    main()
Index: pytest.ini
===================================================================
--- pytest.ini	(revision a69d8cd7ece8a1df933b123d2e032197783d80b4)
+++ pytest.ini	(revision a69d8cd7ece8a1df933b123d2e032197783d80b4)
@@ -0,0 +1,7 @@
+[pytest]
+addopts = --doctest-modules
+doctest_optionflags = ELLIPSIS
+testpaths = sasmodels
+python_files = *.py
+python_classes = NoClassTestsWillMatch
+python_functions = *_test test_* model_tests
Index: sasmodels/bumps_model.py
===================================================================
--- sasmodels/bumps_model.py	(revision 2d81cfe3be42579d563407fb9275b7b516fee234)
+++ sasmodels/bumps_model.py	(revision 49d1f8b8b50df60b60d3cc8b9dfe3c9c22f24db1)
@@ -137,5 +137,5 @@
     """
     _cache = None # type: Dict[str, np.ndarray]
-    def __init__(self, data, model, cutoff=1e-5, name=None):
+    def __init__(self, data, model, cutoff=1e-5, name=None, extra_pars=None):
         # type: (Data, Model, float) -> None
         # remember inputs so we can inspect from outside
@@ -145,4 +145,5 @@
         self._interpret_data(data, model.sasmodel)
         self._cache = {}
+        self.extra_pars = extra_pars
 
     def update(self):
@@ -166,5 +167,8 @@
         Return a dictionary of parameters
         """
-        return self.model.parameters()
+        pars = self.model.parameters()
+        if self.extra_pars:
+            pars.update(self.extra_pars)
+        return pars
 
     def theory(self):
Index: sasmodels/compare.py
===================================================================
--- sasmodels/compare.py	(revision 2a7e20e9c4ba72da5c6088e743a64f3345ee554d)
+++ sasmodels/compare.py	(revision 1fbadb20289a15dcd2b077eb8ef47e0dbda5eef1)
@@ -40,4 +40,5 @@
 from . import core
 from . import kerneldll
+from . import kernelcl
 from .data import plot_theory, empty_data1D, empty_data2D, load_data
 from .direct_model import DirectModel, get_mesh
@@ -623,4 +624,51 @@
 
 
+def time_calculation(calculator, pars, evals=1):
+    # type: (Calculator, ParameterSet, int) -> Tuple[np.ndarray, float]
+    """
+    Compute the average calculation time over N evaluations.
+
+    An additional call is generated without polydispersity in order to
+    initialize the calculation engine, and make the average more stable.
+    """
+    # initialize the code so time is more accurate
+    if evals > 1:
+        calculator(**suppress_pd(pars))
+    toc = tic()
+    # make sure there is at least one eval
+    value = calculator(**pars)
+    for _ in range(evals-1):
+        value = calculator(**pars)
+    average_time = toc()*1000. / evals
+    #print("I(q)",value)
+    return value, average_time
+
+def make_data(opts):
+    # type: (Dict[str, Any]) -> Tuple[Data, np.ndarray]
+    """
+    Generate an empty dataset, used with the model to set Q points
+    and resolution.
+
+    *opts* contains the options, with 'qmax', 'nq', 'res',
+    'accuracy', 'is2d' and 'view' parsed from the command line.
+    """
+    qmin, qmax, nq, res = opts['qmin'], opts['qmax'], opts['nq'], opts['res']
+    if opts['is2d']:
+        q = np.linspace(-qmax, qmax, nq)  # type: np.ndarray
+        data = empty_data2D(q, resolution=res)
+        data.accuracy = opts['accuracy']
+        set_beam_stop(data, qmin)
+        index = ~data.mask
+    else:
+        if opts['view'] == 'log' and not opts['zero']:
+            q = np.logspace(math.log10(qmin), math.log10(qmax), nq)
+        else:
+            q = np.linspace(qmin, qmax, nq)
+        if opts['zero']:
+            q = np.hstack((0, q))
+        data = empty_data1D(q, resolution=res)
+        index = slice(None, None)
+    return data, index
+
 DTYPE_MAP = {
     'half': '16',
@@ -643,10 +691,4 @@
     Return a model calculator using the OpenCL calculation engine.
     """
-    if not core.HAVE_OPENCL:
-        raise RuntimeError("OpenCL not available")
-    model = core.build_model(model_info, dtype=dtype, platform="ocl")
-    calculator = DirectModel(data, model, cutoff=cutoff)
-    calculator.engine = "OCL%s"%DTYPE_MAP[str(model.dtype)]
-    return calculator
 
 def eval_ctypes(model_info, data, dtype='double', cutoff=0.):
@@ -660,51 +702,4 @@
     return calculator
 
-def time_calculation(calculator, pars, evals=1):
-    # type: (Calculator, ParameterSet, int) -> Tuple[np.ndarray, float]
-    """
-    Compute the average calculation time over N evaluations.
-
-    An additional call is generated without polydispersity in order to
-    initialize the calculation engine, and make the average more stable.
-    """
-    # initialize the code so time is more accurate
-    if evals > 1:
-        calculator(**suppress_pd(pars))
-    toc = tic()
-    # make sure there is at least one eval
-    value = calculator(**pars)
-    for _ in range(evals-1):
-        value = calculator(**pars)
-    average_time = toc()*1000. / evals
-    #print("I(q)",value)
-    return value, average_time
-
-def make_data(opts):
-    # type: (Dict[str, Any]) -> Tuple[Data, np.ndarray]
-    """
-    Generate an empty dataset, used with the model to set Q points
-    and resolution.
-
-    *opts* contains the options, with 'qmax', 'nq', 'res',
-    'accuracy', 'is2d' and 'view' parsed from the command line.
-    """
-    qmin, qmax, nq, res = opts['qmin'], opts['qmax'], opts['nq'], opts['res']
-    if opts['is2d']:
-        q = np.linspace(-qmax, qmax, nq)  # type: np.ndarray
-        data = empty_data2D(q, resolution=res)
-        data.accuracy = opts['accuracy']
-        set_beam_stop(data, qmin)
-        index = ~data.mask
-    else:
-        if opts['view'] == 'log' and not opts['zero']:
-            q = np.logspace(math.log10(qmin), math.log10(qmax), nq)
-        else:
-            q = np.linspace(qmin, qmax, nq)
-        if opts['zero']:
-            q = np.hstack((0, q))
-        data = empty_data1D(q, resolution=res)
-        index = slice(None, None)
-    return data, index
-
 def make_engine(model_info, data, dtype, cutoff, ngauss=0):
     # type: (ModelInfo, Data, str, float) -> Calculator
@@ -718,8 +713,14 @@
         set_integration_size(model_info, ngauss)
 
-    if dtype is None or not dtype.endswith('!'):
-        return eval_opencl(model_info, data, dtype=dtype, cutoff=cutoff)
-    else:
-        return eval_ctypes(model_info, data, dtype=dtype[:-1], cutoff=cutoff)
+    if dtype != "default" and not dtype.endswith('!') and not kernelcl.use_opencl():
+        raise RuntimeError("OpenCL not available " + kernelcl.OPENCL_ERROR)
+
+    model = core.build_model(model_info, dtype=dtype, platform="ocl")
+    calculator = DirectModel(data, model, cutoff=cutoff)
+    engine_type = calculator._model.__class__.__name__.replace('Model', '').upper()
+    bits = calculator._model.dtype.itemsize*8
+    precision = "fast" if getattr(calculator._model, 'fast', False) else str(bits)
+    calculator.engine = "%s[%s]" % (engine_type, precision)
+    return calculator
 
 def _show_invalid(data, theory):
@@ -751,8 +752,8 @@
     """
     for k in range(opts['sets']):
-        if k > 1:
+        if k > 0:
             # print a separate seed for each dataset for better reproducibility
             new_seed = np.random.randint(1000000)
-            print("Set %d uses -random=%i"%(k+1, new_seed))
+            print("=== Set %d uses -random=%i ==="%(k+1, new_seed))
             np.random.seed(new_seed)
         opts['pars'] = parse_pars(opts, maxdim=maxdim)
@@ -761,4 +762,7 @@
         result = run_models(opts, verbose=True)
         if opts['plot']:
+            if opts['is2d'] and k > 0:
+                import matplotlib.pyplot as plt
+                plt.figure()
             limits = plot_models(opts, result, limits=limits, setnum=k)
         if opts['show_weights']:
@@ -1328,16 +1332,19 @@
 
     # Evaluate preset parameter expressions
+    # Note: need to replace ':' with '_' in parameter names and expressions
+    # in order to support math on magnetic parameters.
     context = MATH.copy()
     context['np'] = np
-    context.update(pars)
+    context.update((k.replace(':', '_'), v) for k, v in pars.items())
     context.update((k, v) for k, v in presets.items() if isinstance(v, float))
+    #for k,v in sorted(context.items()): print(k, v)
     for k, v in presets.items():
         if not isinstance(v, float) and not k.endswith('_type'):
-            presets[k] = eval(v, context)
+            presets[k] = eval(v.replace(':', '_'), context)
     context.update(presets)
-    context.update((k, v) for k, v in presets2.items() if isinstance(v, float))
+    context.update((k.replace(':', '_'), v) for k, v in presets2.items() if isinstance(v, float))
     for k, v in presets2.items():
         if not isinstance(v, float) and not k.endswith('_type'):
-            presets2[k] = eval(v, context)
+            presets2[k] = eval(v.replace(':', '_'), context)
 
     # update parameters with presets
@@ -1369,5 +1376,5 @@
     path = os.path.dirname(info.filename)
     url = "file://" + path.replace("\\", "/")[2:] + "/"
-    rst2html.view_html_qtapp(html, url)
+    rst2html.view_html_wxapp(html, url)
 
 def explore(opts):
@@ -1490,5 +1497,6 @@
             vmin, vmax = limits
             self.limits = vmax*1e-7, 1.3*vmax
-            import pylab; pylab.clf()
+            import pylab
+            pylab.clf()
             plot_models(self.opts, result, limits=self.limits)
 
Index: sasmodels/convert.py
===================================================================
--- sasmodels/convert.py	(revision 2d81cfe3be42579d563407fb9275b7b516fee234)
+++ sasmodels/convert.py	(revision a69d8cd7ece8a1df933b123d2e032197783d80b4)
@@ -633,6 +633,5 @@
 def test_backward_forward():
     from .core import list_models
+    L = lambda name: _check_one(name, seed=1)
     for name in list_models('all'):
-        L = lambda: _check_one(name, seed=1)
-        L.description = name
-        yield L
+        yield L, name
Index: sasmodels/core.py
===================================================================
--- sasmodels/core.py	(revision 2d81cfe3be42579d563407fb9275b7b516fee234)
+++ sasmodels/core.py	(revision 3221de0ed9b3480163246ceb0503432126bd5134)
@@ -21,21 +21,7 @@
 from . import mixture
 from . import kernelpy
+from . import kernelcl
 from . import kerneldll
 from . import custom
-
-if os.environ.get("SAS_OPENCL", "").lower() == "none":
-    HAVE_OPENCL = False
-else:
-    try:
-        from . import kernelcl
-        HAVE_OPENCL = True
-    except Exception:
-        HAVE_OPENCL = False
-
-CUSTOM_MODEL_PATH = os.environ.get('SAS_MODELPATH', "")
-if CUSTOM_MODEL_PATH == "":
-    CUSTOM_MODEL_PATH = joinpath(os.path.expanduser("~"), ".sasmodels", "custom_models")
-    if not os.path.isdir(CUSTOM_MODEL_PATH):
-        os.makedirs(CUSTOM_MODEL_PATH)
 
 # pylint: disable=unused-import
@@ -47,4 +33,10 @@
     pass
 # pylint: enable=unused-import
+
+CUSTOM_MODEL_PATH = os.environ.get('SAS_MODELPATH', "")
+if CUSTOM_MODEL_PATH == "":
+    CUSTOM_MODEL_PATH = joinpath(os.path.expanduser("~"), ".sasmodels", "custom_models")
+    if not os.path.isdir(CUSTOM_MODEL_PATH):
+        os.makedirs(CUSTOM_MODEL_PATH)
 
 # TODO: refactor composite model support
@@ -148,47 +140,31 @@
     used with functions in generate to build the docs or extract model info.
     """
-    if '@' in model_string:
-        parts = model_string.split('@')
-        if len(parts) != 2:
-            raise ValueError("Use P@S to apply a structure factor S to model P")
-        P_info, Q_info = [load_model_info(part) for part in parts]
-        return product.make_product_info(P_info, Q_info)
-
-    product_parts = []
-    addition_parts = []
-
-    addition_parts_names = model_string.split('+')
-    if len(addition_parts_names) >= 2:
-        addition_parts = [load_model_info(part) for part in addition_parts_names]
-    elif len(addition_parts_names) == 1:
-        product_parts_names = model_string.split('*')
-        if len(product_parts_names) >= 2:
-            product_parts = [load_model_info(part) for part in product_parts_names]
-        elif len(product_parts_names) == 1:
-            if "custom." in product_parts_names[0]:
-                # Extract ModelName from "custom.ModelName"
-                pattern = "custom.([A-Za-z0-9_-]+)"
-                result = re.match(pattern, product_parts_names[0])
-                if result is None:
-                    raise ValueError("Model name in invalid format: " + product_parts_names[0])
-                model_name = result.group(1)
-                # Use ModelName to find the path to the custom model file
-                model_path = joinpath(CUSTOM_MODEL_PATH, model_name + ".py")
-                if not os.path.isfile(model_path):
-                    raise ValueError("The model file {} doesn't exist".format(model_path))
-                kernel_module = custom.load_custom_kernel_module(model_path)
-                return modelinfo.make_model_info(kernel_module)
-            # Model is a core model
-            kernel_module = generate.load_kernel_module(product_parts_names[0])
-            return modelinfo.make_model_info(kernel_module)
-
-    model = None
-    if len(product_parts) > 1:
-        model = mixture.make_mixture_info(product_parts, operation='*')
-    if len(addition_parts) > 1:
-        if model is not None:
-            addition_parts.append(model)
-        model = mixture.make_mixture_info(addition_parts, operation='+')
-    return model
+    if "+" in model_string:
+        parts = [load_model_info(part)
+                 for part in model_string.split("+")]
+        return mixture.make_mixture_info(parts, operation='+')
+    elif "*" in model_string:
+        parts = [load_model_info(part)
+                 for part in model_string.split("*")]
+        return mixture.make_mixture_info(parts, operation='*')
+    elif "@" in model_string:
+        p_info, q_info = [load_model_info(part)
+                          for part in model_string.split("@")]
+        return product.make_product_info(p_info, q_info)
+    # We are now dealing with a pure model
+    elif "custom." in model_string:
+        pattern = "custom.([A-Za-z0-9_-]+)"
+        result = re.match(pattern, model_string)
+        if result is None:
+            raise ValueError("Model name in invalid format: " + model_string)
+        model_name = result.group(1)
+        # Use ModelName to find the path to the custom model file
+        model_path = joinpath(CUSTOM_MODEL_PATH, model_name + ".py")
+        if not os.path.isfile(model_path):
+            raise ValueError("The model file {} doesn't exist".format(model_path))
+        kernel_module = custom.load_custom_kernel_module(model_path)
+        return modelinfo.make_model_info(kernel_module)
+    kernel_module = generate.load_kernel_module(model_string)
+    return modelinfo.make_model_info(kernel_module)
 
 
@@ -292,5 +268,5 @@
     if platform is None:
         platform = "ocl"
-    if platform == "ocl" and not HAVE_OPENCL or not model_info.opencl:
+    if not kernelcl.use_opencl() or not model_info.opencl:
         platform = "dll"
 
@@ -336,4 +312,61 @@
     print("\n".join(list_models(kind)))
 
+def test_composite_order():
+    def test_models(fst, snd):
+        """Confirm that two models produce the same parameters"""
+        fst = load_model(fst)
+        snd = load_model(snd)
+        # Un-disambiguate parameter names so that we can check if the same
+        # parameters are in a pair of composite models. Since each parameter in
+        # the mixture model is tagged as e.g., A_sld, we ought to use a
+        # regex subsitution s/^[A-Z]+_/_/, but removing all uppercase letters
+        # is good enough.
+        fst = [[x for x in p.name if x == x.lower()] for p in fst.info.parameters.kernel_parameters]
+        snd = [[x for x in p.name if x == x.lower()] for p in snd.info.parameters.kernel_parameters]
+        assert sorted(fst) == sorted(snd), "{} != {}".format(fst, snd)
+
+    def build_test(first, second):
+        test = lambda description: test_models(first, second)
+        description = first + " vs. " + second
+        return test, description
+
+    yield build_test(
+        "cylinder+sphere",
+        "sphere+cylinder")
+    yield build_test(
+        "cylinder*sphere",
+        "sphere*cylinder")
+    yield build_test(
+        "cylinder@hardsphere*sphere",
+        "sphere*cylinder@hardsphere")
+    yield build_test(
+        "barbell+sphere*cylinder@hardsphere",
+        "sphere*cylinder@hardsphere+barbell")
+    yield build_test(
+        "barbell+cylinder@hardsphere*sphere",
+        "cylinder@hardsphere*sphere+barbell")
+    yield build_test(
+        "barbell+sphere*cylinder@hardsphere",
+        "barbell+cylinder@hardsphere*sphere")
+    yield build_test(
+        "sphere*cylinder@hardsphere+barbell",
+        "cylinder@hardsphere*sphere+barbell")
+    yield build_test(
+        "barbell+sphere*cylinder@hardsphere",
+        "cylinder@hardsphere*sphere+barbell")
+    yield build_test(
+        "barbell+cylinder@hardsphere*sphere",
+        "sphere*cylinder@hardsphere+barbell")
+
+def test_composite():
+    # type: () -> None
+    """Check that model load works"""
+    #Test the the model produces the parameters that we would expect
+    model = load_model("cylinder@hardsphere*sphere")
+    actual = [p.name for p in model.info.parameters.kernel_parameters]
+    target = ("sld sld_solvent radius length theta phi volfraction"
+              " A_sld A_sld_solvent A_radius").split()
+    assert target == actual, "%s != %s"%(target, actual)
+
 if __name__ == "__main__":
     list_models_main()
Index: sasmodels/data.py
===================================================================
--- sasmodels/data.py	(revision 2d81cfe3be42579d563407fb9275b7b516fee234)
+++ sasmodels/data.py	(revision d86f0fc0fff835c5ddf398e64c046518fa4536fc)
@@ -706,5 +706,7 @@
     else:
         vmin_scaled, vmax_scaled = vmin, vmax
-    plt.imshow(plottable.reshape(len(data.x_bins), len(data.y_bins)),
+    #nx, ny = len(data.x_bins), len(data.y_bins)
+    x_bins, y_bins, image = _build_matrix(data, plottable)
+    plt.imshow(image,
                interpolation='nearest', aspect=1, origin='lower',
                extent=[xmin, xmax, ymin, ymax],
@@ -714,4 +716,174 @@
     return vmin, vmax
 
+
+# === The following is modified from sas.sasgui.plottools.PlotPanel
+def _build_matrix(self, plottable):
+    """
+    Build a matrix for 2d plot from a vector
+    Returns a matrix (image) with ~ square binning
+    Requirement: need 1d array formats of
+    self.data, self.qx_data, and self.qy_data
+    where each one corresponds to z, x, or y axis values
+
+    """
+    # No qx or qy given in a vector format
+    if self.qx_data is None or self.qy_data is None \
+            or self.qx_data.ndim != 1 or self.qy_data.ndim != 1:
+        return self.x_bins, self.y_bins, plottable
+
+    # maximum # of loops to fillup_pixels
+    # otherwise, loop could never stop depending on data
+    max_loop = 1
+    # get the x and y_bin arrays.
+    x_bins, y_bins = _get_bins(self)
+    # set zero to None
+
+    #Note: Can not use scipy.interpolate.Rbf:
+    # 'cause too many data points (>10000)<=JHC.
+    # 1d array to use for weighting the data point averaging
+    #when they fall into a same bin.
+    weights_data = np.ones([self.data.size])
+    # get histogram of ones w/len(data); this will provide
+    #the weights of data on each bins
+    weights, xedges, yedges = np.histogram2d(x=self.qy_data,
+                                             y=self.qx_data,
+                                             bins=[y_bins, x_bins],
+                                             weights=weights_data)
+    # get histogram of data, all points into a bin in a way of summing
+    image, xedges, yedges = np.histogram2d(x=self.qy_data,
+                                           y=self.qx_data,
+                                           bins=[y_bins, x_bins],
+                                           weights=plottable)
+    # Now, normalize the image by weights only for weights>1:
+    # If weight == 1, there is only one data point in the bin so
+    # that no normalization is required.
+    image[weights > 1] = image[weights > 1] / weights[weights > 1]
+    # Set image bins w/o a data point (weight==0) as None (was set to zero
+    # by histogram2d.)
+    image[weights == 0] = None
+
+    # Fill empty bins with 8 nearest neighbors only when at least
+    #one None point exists
+    loop = 0
+
+    # do while loop until all vacant bins are filled up up
+    #to loop = max_loop
+    while (weights == 0).any():
+        if loop >= max_loop:  # this protects never-ending loop
+            break
+        image = _fillup_pixels(image=image, weights=weights)
+        loop += 1
+
+    return x_bins, y_bins, image
+
+def _get_bins(self):
+    """
+    get bins
+    set x_bins and y_bins into self, 1d arrays of the index with
+    ~ square binning
+    Requirement: need 1d array formats of
+    self.qx_data, and self.qy_data
+    where each one corresponds to  x, or y axis values
+    """
+    # find max and min values of qx and qy
+    xmax = self.qx_data.max()
+    xmin = self.qx_data.min()
+    ymax = self.qy_data.max()
+    ymin = self.qy_data.min()
+
+    # calculate the range of qx and qy: this way, it is a little
+    # more independent
+    x_size = xmax - xmin
+    y_size = ymax - ymin
+
+    # estimate the # of pixels on each axes
+    npix_y = int(np.floor(np.sqrt(len(self.qy_data))))
+    npix_x = int(np.floor(len(self.qy_data) / npix_y))
+
+    # bin size: x- & y-directions
+    xstep = x_size / (npix_x - 1)
+    ystep = y_size / (npix_y - 1)
+
+    # max and min taking account of the bin sizes
+    xmax = xmax + xstep / 2.0
+    xmin = xmin - xstep / 2.0
+    ymax = ymax + ystep / 2.0
+    ymin = ymin - ystep / 2.0
+
+    # store x and y bin centers in q space
+    x_bins = np.linspace(xmin, xmax, npix_x)
+    y_bins = np.linspace(ymin, ymax, npix_y)
+
+    return x_bins, y_bins
+
+def _fillup_pixels(image=None, weights=None):
+    """
+    Fill z values of the empty cells of 2d image matrix
+    with the average over up-to next nearest neighbor points
+
+    :param image: (2d matrix with some zi = None)
+
+    :return: image (2d array )
+
+    :TODO: Find better way to do for-loop below
+
+    """
+    # No image matrix given
+    if image is None or np.ndim(image) != 2 \
+            or np.isfinite(image).all() \
+            or weights is None:
+        return image
+    # Get bin size in y and x directions
+    len_y = len(image)
+    len_x = len(image[1])
+    temp_image = np.zeros([len_y, len_x])
+    weit = np.zeros([len_y, len_x])
+    # do for-loop for all pixels
+    for n_y in range(len(image)):
+        for n_x in range(len(image[1])):
+            # find only null pixels
+            if weights[n_y][n_x] > 0 or np.isfinite(image[n_y][n_x]):
+                continue
+            else:
+                # find 4 nearest neighbors
+                # check where or not it is at the corner
+                if n_y != 0 and np.isfinite(image[n_y - 1][n_x]):
+                    temp_image[n_y][n_x] += image[n_y - 1][n_x]
+                    weit[n_y][n_x] += 1
+                if n_x != 0 and np.isfinite(image[n_y][n_x - 1]):
+                    temp_image[n_y][n_x] += image[n_y][n_x - 1]
+                    weit[n_y][n_x] += 1
+                if n_y != len_y - 1 and np.isfinite(image[n_y + 1][n_x]):
+                    temp_image[n_y][n_x] += image[n_y + 1][n_x]
+                    weit[n_y][n_x] += 1
+                if n_x != len_x - 1 and np.isfinite(image[n_y][n_x + 1]):
+                    temp_image[n_y][n_x] += image[n_y][n_x + 1]
+                    weit[n_y][n_x] += 1
+                # go 4 next nearest neighbors when no non-zero
+                # neighbor exists
+                if n_y != 0 and n_x != 0 and \
+                        np.isfinite(image[n_y - 1][n_x - 1]):
+                    temp_image[n_y][n_x] += image[n_y - 1][n_x - 1]
+                    weit[n_y][n_x] += 1
+                if n_y != len_y - 1 and n_x != 0 and \
+                        np.isfinite(image[n_y + 1][n_x - 1]):
+                    temp_image[n_y][n_x] += image[n_y + 1][n_x - 1]
+                    weit[n_y][n_x] += 1
+                if n_y != len_y and n_x != len_x - 1 and \
+                        np.isfinite(image[n_y - 1][n_x + 1]):
+                    temp_image[n_y][n_x] += image[n_y - 1][n_x + 1]
+                    weit[n_y][n_x] += 1
+                if n_y != len_y - 1 and n_x != len_x - 1 and \
+                        np.isfinite(image[n_y + 1][n_x + 1]):
+                    temp_image[n_y][n_x] += image[n_y + 1][n_x + 1]
+                    weit[n_y][n_x] += 1
+
+    # get it normalized
+    ind = (weit > 0)
+    image[ind] = temp_image[ind] / weit[ind]
+
+    return image
+
+
 def demo():
     # type: () -> None
Index: sasmodels/details.py
===================================================================
--- sasmodels/details.py	(revision 108e70e22154db937a6d46f66be6dd70408cce6d)
+++ sasmodels/details.py	(revision 885753a925cb7e3423bdd0db6db395557a6fb1c6)
@@ -243,5 +243,5 @@
     offset = np.cumsum(np.hstack((0, length)))
     call_details = make_details(kernel.info, length, offset[:-1], offset[-1])
-    # Pad value array to a 32 value boundaryd
+    # Pad value array to a 32 value boundary
     data_len = nvalues + 2*sum(len(v) for v in dispersity)
     extra = (32 - data_len%32)%32
@@ -250,4 +250,5 @@
     is_magnetic = convert_magnetism(kernel.info.parameters, data)
     #call_details.show()
+    #print("data", data)
     return call_details, data, is_magnetic
 
@@ -296,11 +297,10 @@
     mag = values[parameters.nvalues-3*parameters.nmagnetic:parameters.nvalues]
     mag = mag.reshape(-1, 3)
-    scale = mag[:, 0]
-    if np.any(scale):
+    if np.any(mag[:, 0] != 0.0):
+        M0 = mag[:, 0].copy()
         theta, phi = radians(mag[:, 1]), radians(mag[:, 2])
-        cos_theta = cos(theta)
-        mag[:, 0] = scale*cos_theta*cos(phi)  # mx
-        mag[:, 1] = scale*sin(theta)  # my
-        mag[:, 2] = -scale*cos_theta*sin(phi)  # mz
+        mag[:, 0] = +M0*cos(theta)*cos(phi)  # mx
+        mag[:, 1] = +M0*sin(theta) # my
+        mag[:, 2] = -M0*cos(theta)*sin(phi)  # mz
         return True
     else:
Index: sasmodels/direct_model.py
===================================================================
--- sasmodels/direct_model.py	(revision 2d81cfe3be42579d563407fb9275b7b516fee234)
+++ sasmodels/direct_model.py	(revision d18d6ddf640c8cff5c76cfac900c1940989612ca)
@@ -345,4 +345,9 @@
             self._kernel = self._model.make_kernel(self._kernel_inputs)
 
+        # Need to pull background out of resolution for multiple scattering
+        background = pars.get('background', 0.)
+        pars = pars.copy()
+        pars['background'] = 0.
+
         Iq_calc = call_kernel(self._kernel, pars, cutoff=cutoff)
         # Storing the calculated Iq values so that they can be plotted.
@@ -357,5 +362,5 @@
                 np.reshape(Iq_calc, (self.resolution.ny, self.resolution.nx))
             )
-        return result
+        return result + background
 
 
Index: sasmodels/generate.py
===================================================================
--- sasmodels/generate.py	(revision 108e70e22154db937a6d46f66be6dd70408cce6d)
+++ sasmodels/generate.py	(revision d86f0fc0fff835c5ddf398e64c046518fa4536fc)
@@ -169,5 +169,6 @@
 
 import sys
-from os.path import abspath, dirname, join as joinpath, exists, getmtime
+from os import environ
+from os.path import abspath, dirname, join as joinpath, exists, getmtime, sep
 import re
 import string
@@ -214,5 +215,5 @@
 
 EXTERNAL_DIR = 'sasmodels-data'
-DATA_PATH = get_data_path(EXTERNAL_DIR, 'kernel_template.c')
+DATA_PATH = get_data_path(EXTERNAL_DIR, 'kernel_iq.c')
 MODEL_PATH = joinpath(DATA_PATH, 'models')
 
@@ -289,12 +290,11 @@
     import loops.
     """
-    if (info.source and any(lib.startswith('lib/gauss') for lib in info.source)):
-        import os.path
+    if info.source and any(lib.startswith('lib/gauss') for lib in info.source):
         from .gengauss import gengauss
-        path = os.path.join(MODEL_PATH, "lib", "gauss%d.c"%n)
-        if not os.path.exists(path):
+        path = joinpath(MODEL_PATH, "lib", "gauss%d.c"%n)
+        if not exists(path):
             gengauss(n, path)
         info.source = ["lib/gauss%d.c"%n if lib.startswith('lib/gauss')
-                        else lib for lib in info.source]
+                       else lib for lib in info.source]
 
 def format_units(units):
@@ -320,10 +320,10 @@
                      for w, h in zip(column_widths, PARTABLE_HEADERS)]
 
-    sep = " ".join("="*w for w in column_widths)
+    underbar = " ".join("="*w for w in column_widths)
     lines = [
-        sep,
+        underbar,
         " ".join("%-*s" % (w, h)
                  for w, h in zip(column_widths, PARTABLE_HEADERS)),
-        sep,
+        underbar,
         ]
     for p in pars:
@@ -334,5 +334,5 @@
             "%*g" % (column_widths[3], p.default),
             ]))
-    lines.append(sep)
+    lines.append(underbar)
     return "\n".join(lines)
 
@@ -389,5 +389,5 @@
     # TODO: fails DRY; templates appear two places.
     model_templates = [joinpath(DATA_PATH, filename)
-                       for filename in ('kernel_header.c', 'kernel_iq.cl')]
+                       for filename in ('kernel_header.c', 'kernel_iq.c')]
     source_files = (model_sources(model_info)
                     + model_templates
@@ -612,6 +612,6 @@
     """
     spaces = " "*depth
-    sep = "\n" + spaces
-    return spaces + sep.join(s.split("\n"))
+    interline_separator = "\n" + spaces
+    return spaces + interline_separator.join(s.split("\n"))
 
 
@@ -619,4 +619,7 @@
 def load_template(filename):
     # type: (str) -> str
+    """
+    Load template file from sasmodels resource directory.
+    """
     path = joinpath(DATA_PATH, filename)
     mtime = getmtime(path)
@@ -900,7 +903,17 @@
         kernel_module = load_custom_kernel_module(model_name)
     else:
-        from sasmodels import models
-        __import__('sasmodels.models.'+model_name)
-        kernel_module = getattr(models, model_name, None)
+        try:
+            from sasmodels import models
+            __import__('sasmodels.models.'+model_name)
+            kernel_module = getattr(models, model_name, None)
+        except ImportError:
+            # If the model isn't a built in model, try the plugin directory
+            plugin_path = environ.get('SAS_MODELPATH', None)
+            if plugin_path is not None:
+                file_name = model_name.split(sep)[-1]
+                model_name = plugin_path + sep + file_name + ".py"
+                kernel_module = load_custom_kernel_module(model_name)
+            else:
+                raise
     return kernel_module
 
Index: sasmodels/guyou.py
===================================================================
--- sasmodels/guyou.py	(revision b3703f51bafe67afb8acefe986ad7466720f755b)
+++ sasmodels/guyou.py	(revision b3703f51bafe67afb8acefe986ad7466720f755b)
@@ -0,0 +1,390 @@
+# Copyright 2013-2016 Mike Bostock
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without modification,
+# are permitted provided that the following conditions are met:
+#
+# * Redistributions of source code must retain the above copyright notice, this
+#   list of conditions and the following disclaimer.
+#
+# * Redistributions in binary form must reproduce the above copyright notice,
+#   this list of conditions and the following disclaimer in the documentation
+#   and/or other materials provided with the distribution.
+#
+# * Neither the name of the author nor the names of contributors may be used to
+#   endorse or promote products derived from this software without specific prior
+#   written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+# https://github.com/d3/d3-geo-projection
+# commit fd2886555e46b35163c7b898d43c7d1bcebbba7c 2016-07-02
+#
+# 2017-11-01 Paul Kienzle
+# * converted to python, with degrees rather than radians
+"""
+Convert between latitude-longitude and Guyou map coordinates.
+"""
+
+from __future__ import division, print_function
+
+import numpy as np
+from numpy import sqrt, pi, tan, cos, sin, sign, radians, degrees
+from numpy import sinh, arctan as atan
+
+# scipy version of special functions
+from scipy.special import ellipj as ellipticJ, ellipkinc as ellipticF
+
+_ = """
+# mpmath version of special functions
+import mpmath as mp
+sn, cn, dn = [mp.ellipfun(v) for v in 'sn', 'cn', 'dn']
+
+def ellipticJi(u, v, m):
+    z = u+v*1j
+    return sn(z, m), cn(z, m), dn(z, m)
+
+def ellipticJ(u, m):
+    s, c, d = sn(u, m), cn(u, m), dn(u, m)
+    phi = mp.asin(s)
+    return s, c, d, phi
+
+def ellipticFi(phi, psi, m):
+    z = phi + psi*1j
+    return mp.ellipf(z, m)
+
+def ellipticF(phi, m):
+    return mp.ellipf(phi, m)
+"""
+
+def ellipticJi(u, v, m):
+    scalar = np.isscalar(u) and np.isscalar(v) and np.isscalar(m)
+    u, v, m = np.broadcast_arrays(u, v, m)
+    result = np.empty_like([u, u, u], 'D')
+    real = (v == 0)
+    imag = (u == 0)
+    mixed = ~(real|imag)
+    result[:, real] = _ellipticJi_real(u[real], m[real])
+    result[:, imag] = _ellipticJi_imag(v[imag], m[imag])
+    result[:, mixed] = _ellipticJi(u[mixed], v[mixed], m[mixed])
+    return result[0, :] if scalar else result
+
+def _ellipticJi_real(u, m):
+    sn, cn, dn, phi = ellipticJ(u, m)
+    return sn, cn, dn
+
+def _ellipticJi_imag(v, m):
+    sn, cn, dn, phi = ellipticJ(v, 1-m)
+    return 1j*sn/cn, 1/cn, dn/cn
+
+def _ellipticJi(u, v, m):
+    # Ignoring special cases for now.
+    #   u=0: (1j*b[0]/b[1], 1/b[1], b[2]/b[1])
+    #   v=0: (a[0], a[1], a[2])
+    a = ellipticJ(u, m)
+    b = ellipticJ(v, 1 - m)
+    c = b[1]**2 + m * (a[0] * b[0])**2
+    return [
+        (a[0] * b[2] / c) + 1j*(a[1] * a[2] * b[0] * b[1] / c),
+        (a[1] * b[1] / c) + 1j*(-a[0] * a[2] * b[0] * b[2] / c),
+        (a[2] * b[1] * b[2] / c) + 1j*(-m * a[0] * a[1] * b[0] / c),
+    ]
+
+# calculate F(phi+ipsi|m).
+# see Abramowitz and Stegun, 17.4.11.
+def ellipticFi(phi, psi, m):
+    if np.any(phi == 0):
+        scalar = np.isscalar(phi) and np.isscalar(psi) and np.isscalar(m)
+        phi, psi, m = np.broadcast_arrays(phi, psi, m)
+        result = np.empty_like(phi, 'D')
+        index = (phi == 0)
+        result[index] = ellipticF(atan(sinh(abs(phi[index]))),
+                                  1-m[index]) * sign(psi[index])
+        result[~index] = ellipticFi(phi[~index], psi[~index], m[~index])
+        return result.reshape(1)[0] if scalar else result
+
+    r = abs(phi)
+    i = abs(psi)
+    sinhpsi2 = sinh(i)**2
+    cscphi2 = 1 / sin(r)**2
+    cotphi2 = 1 / tan(r)**2
+    b = -(cotphi2 + m * (sinhpsi2 * cscphi2) - 1 + m)
+    c = (m - 1) * cotphi2
+    cotlambda2 = (-b + sqrt(b * b - 4 * c)) / 2
+    re = ellipticF(atan(1 / sqrt(cotlambda2)), m) * sign(phi)
+    im = ellipticF(atan(sqrt(np.maximum(0, (cotlambda2 / cotphi2 - 1) / m))),
+                   1 - m) * sign(psi)
+    return re + 1j*im
+
+SQRT2 = sqrt(2)
+
+# [PAK] renamed k_ => cos_u, k => sin_u, k*k => sinsq_u to avoid k,K confusion
+# cos_u = 0.171572875253809902396622551580603842860656249246103853646...
+# sinsq_u = 0.970562748477140585620264690516376942836062504523376878120...
+# K = 3.165103454447431823666270142140819753058976299237578486994...
+def guyou(lam, phi):
+    """Transform from (latitude, longitude) to point (x, y)"""
+    # [PAK] wrap into [-pi/2, pi/2] radians
+    x, y = np.asarray(lam), np.asarray(phi)
+    xn, x = divmod(x+90, 180)
+    yn, y = divmod(y+90, 180)
+    xn, lam = xn*180, radians(x-90)
+    yn, phi = yn*180, radians(y-90)
+
+    # Compute constant K
+    cos_u = (SQRT2 - 1) / (SQRT2 + 1)
+    sinsq_u = 1 - cos_u**2
+    K = ellipticF(pi/2, sinsq_u)
+
+    # [PAK] simplify expressions, using the fact that f = -1
+    # Note: exp(f log(x)) => 1/x,  cos(f x) => cos(x), sin(f x) => -sin(x)
+    r = 1/(tan(pi/4 + abs(phi)/2) * sqrt(cos_u))
+    at = atan(r * (cos(lam) - 1j*sin(lam)))
+    t = ellipticFi(at.real, at.imag, sinsq_u)
+    x, y = (-t.imag, sign(phi + (phi == 0))*(0.5 * K - t.real))
+
+    # [PAK] convert to degrees, and return to original tile
+    return degrees(x)+xn, degrees(y)+yn
+
+def guyou_invert(x, y):
+    """Transform from point (x, y) on plot to (latitude, longitude)"""
+    # [PAK] wrap into [-pi/2, pi/2] radians
+    x, y = np.asarray(x), np.asarray(y)
+    xn, x = divmod(x+90, 180)
+    yn, y = divmod(y+90, 180)
+    xn, x = xn*180, radians(x-90)
+    yn, y = yn*180, radians(y-90)
+
+    # compute constant K
+    cos_u = (SQRT2 - 1) / (SQRT2 + 1)
+    sinsq_u = 1 - cos_u**2
+    K = ellipticF(pi/2, sinsq_u)
+
+    # [PAK] simplify expressions, using the fact that f = -1
+    j = ellipticJi(K/2 - y, -x, sinsq_u)
+    tn = j[0]/j[1]  # j[0], j[1] are complex
+    # Note: -atan2(im(x)/re(x)) => angle(x)
+    lam = -np.angle(tn)
+    # Note: exp(0.5/f log(a re(x)^2 + a im(x)^2)) => 1/(sqrt(a) |x|)
+    phi = 2*atan(1/sqrt(cos_u)/abs(tn)) - pi/2
+
+    # [PAK] convert to degrees, and return to original tile
+    return degrees(lam)+xn, degrees(phi)+yn
+
+def plot_grid():
+    """Plot the latitude-longitude grid for Guyou transform"""
+    import matplotlib.pyplot as plt
+    from numpy import linspace
+    lat_line = linspace(-90, 90, 400)
+    long_line = linspace(-90, 90, 400)
+
+    #scale = 1
+    limit, step, scale = 90, 10, 2
+    plt.subplot(211)
+    for lat in range(-limit, limit+1, step):
+        x, y = guyou(scale*lat_line, scale*lat)
+        plt.plot(x, y, 'g')
+
+    for longitude in range(-limit, limit+1, step):
+        x, y = guyou(scale*longitude, scale*long_line)
+        plt.plot(x, y, 'b')
+    #plt.xlabel('longitude')
+    plt.ylabel('latitude')
+    plt.title('forward transform')
+
+    plt.subplot(212)
+    for lat in range(-limit, limit+1, step):
+        x, y = guyou_invert(scale*lat_line, scale*lat)
+        plt.plot(x, y, 'g')
+
+    for long in range(-limit, limit+1, step):
+        x, y = guyou_invert(scale*long, scale*long_line)
+        plt.plot(x, y, 'b')
+    plt.xlabel('longitude')
+    plt.ylabel('latitude')
+    plt.title('inverse transform')
+
+def main():
+    """Show the Guyou transformation"""
+    plot_grid()
+    import matplotlib.pyplot as plt
+    plt.show()
+
+if __name__ == "__main__":
+    main()
+
+_ = """
+// Javascript source for elliptic functions
+//
+// Returns [sn, cn, dn](u + iv|m).
+export function ellipticJi(u, v, m) {
+  var a, b, c;
+  if (!u) {
+    b = ellipticJ(v, 1 - m);
+    return [
+      [0, b[0] / b[1]],
+      [1 / b[1], 0],
+      [b[2] / b[1], 0]
+    ];
+  }
+  a = ellipticJ(u, m);
+  if (!v) return [[a[0], 0], [a[1], 0], [a[2], 0]];
+  b = ellipticJ(v, 1 - m);
+  c = b[1] * b[1] + m * a[0] * a[0] * b[0] * b[0];
+  return [
+    [a[0] * b[2] / c, a[1] * a[2] * b[0] * b[1] / c],
+    [a[1] * b[1] / c, -a[0] * a[2] * b[0] * b[2] / c],
+    [a[2] * b[1] * b[2] / c, -m * a[0] * a[1] * b[0] / c]
+  ];
+}
+
+// Returns [sn, cn, dn, ph](u|m).
+export function ellipticJ(u, m) {
+  var ai, b, phi, t, twon;
+  if (m < epsilon) {
+    t = sin(u);
+    b = cos(u);
+    ai = m * (u - t * b) / 4;
+    return [
+      t - ai * b,
+      b + ai * t,
+      1 - m * t * t / 2,
+      u - ai
+    ];
+  }
+  if (m >= 1 - epsilon) {
+    ai = (1 - m) / 4;
+    b = cosh(u);
+    t = tanh(u);
+    phi = 1 / b;
+    twon = b * sinh(u);
+    return [
+      t + ai * (twon - u) / (b * b),
+      phi - ai * t * phi * (twon - u),
+      phi + ai * t * phi * (twon + u),
+      2 * atan(exp(u)) - halfPi + ai * (twon - u) / b
+    ];
+  }
+
+  var a = [1, 0, 0, 0, 0, 0, 0, 0, 0],
+      c = [sqrt(m), 0, 0, 0, 0, 0, 0, 0, 0],
+      i = 0;
+  b = sqrt(1 - m);
+  twon = 1;
+
+  while (abs(c[i] / a[i]) > epsilon && i < 8) {
+    ai = a[i++];
+    c[i] = (ai - b) / 2;
+    a[i] = (ai + b) / 2;
+    b = sqrt(ai * b);
+    twon *= 2;
+  }
+
+  phi = twon * a[i] * u;
+  do {
+    t = c[i] * sin(b = phi) / a[i];
+    phi = (asin(t) + phi) / 2;
+  } while (--i);
+
+  // [PAK] Cephes uses dn = sqrt(1 - m*sin^2 phi) rather than cos(phi)/cos(phi-b)
+  // DLMF says the second version is unstable near x = K.
+  return [sin(phi), t = cos(phi), t / cos(phi - b), phi];
+}
+
+// calculate F(phi+ipsi|m).
+// see Abramowitz and Stegun, 17.4.11.
+export function ellipticFi(phi, psi, m) {
+  var r = abs(phi),
+      i = abs(psi),
+      sinhpsi = sinh(i);
+  if (r) {
+    var cscphi = 1 / sin(r),
+        cotphi2 = 1 / (tan(r) * tan(r)),
+        b = -(cotphi2 + m * (sinhpsi * sinhpsi * cscphi * cscphi) - 1 + m),
+        c = (m - 1) * cotphi2,
+        cotlambda2 = (-b + sqrt(b * b - 4 * c)) / 2;
+    return [
+      ellipticF(atan(1 / sqrt(cotlambda2)), m) * sign(phi),
+      ellipticF(atan(sqrt((cotlambda2 / cotphi2 - 1) / m)), 1 - m) * sign(psi)
+    ];
+  }
+  return [
+    0,
+    ellipticF(atan(sinhpsi), 1 - m) * sign(psi)
+  ];
+}
+
+// Calculate F(phi|m) where m = k^2 = sin(alpha)^2.
+// See Abramowitz and Stegun, 17.6.7.
+export function ellipticF(phi, m) {
+  if (!m) return phi;
+  if (m === 1) return log(tan(phi / 2 + quarterPi));
+  var a = 1,
+      b = sqrt(1 - m),
+      c = sqrt(m);
+  for (var i = 0; abs(c) > epsilon; i++) {
+    if (phi % pi) {
+      var dPhi = atan(b * tan(phi) / a);
+      if (dPhi < 0) dPhi += pi;
+      phi += dPhi + ~~(phi / pi) * pi;
+    } else phi += phi;
+    c = (a + b) / 2;
+    b = sqrt(a * b);
+    c = ((a = c) - b) / 2;
+  }
+  return phi / (pow(2, i) * a);
+
+
+export function guyouRaw(lambda, phi) {
+  var k_ = (sqrt2 - 1) / (sqrt2 + 1),
+      k = sqrt(1 - k_ * k_),
+      K = ellipticF(halfPi, k * k),
+      f = -1,
+      psi = log(tan(pi / 4 + abs(phi) / 2)),
+      r = exp(f * psi) / sqrt(k_),
+      at = guyouComplexAtan(r * cos(f * lambda), r * sin(f * lambda)),
+      t = ellipticFi(at[0], at[1], k * k);
+  return [-t[1], (phi >= 0 ? 1 : -1) * (0.5 * K - t[0])];
+}
+
+function guyouComplexAtan(x, y) {
+  var x2 = x * x,
+      y_1 = y + 1,
+      t = 1 - x2 - y * y;
+  return [
+   0.5 * ((x >= 0 ? halfPi : -halfPi) - atan2(t, 2 * x)),
+    -0.25 * log(t * t + 4 * x2) +0.5 * log(y_1 * y_1 + x2)
+  ];
+}
+
+function guyouComplexDivide(a, b) {
+  var denominator = b[0] * b[0] + b[1] * b[1];
+  return [
+    (a[0] * b[0] + a[1] * b[1]) / denominator,
+    (a[1] * b[0] - a[0] * b[1]) / denominator
+  ];
+}
+
+guyouRaw.invert = function(x, y) {
+  var k_ = (sqrt2 - 1) / (sqrt2 + 1),
+      k = sqrt(1 - k_ * k_),
+      K = ellipticF(halfPi, k * k),
+      f = -1,
+      j = ellipticJi(0.5 * K - y, -x, k * k),
+      tn = guyouComplexDivide(j[0], j[1]),
+      lambda = atan2(tn[1], tn[0]) / f;
+  return [
+    lambda,
+    2 * atan(exp(0.5 / f * log(k_ * tn[0] * tn[0] + k_ * tn[1] * tn[1]))) - halfPi
+  ];
+};
+"""
Index: sasmodels/jitter.py
===================================================================
--- sasmodels/jitter.py	(revision b3703f51bafe67afb8acefe986ad7466720f755b)
+++ sasmodels/jitter.py	(revision b3703f51bafe67afb8acefe986ad7466720f755b)
@@ -0,0 +1,824 @@
+#!/usr/bin/env python
+"""
+Jitter Explorer
+===============
+
+Application to explore orientation angle and angular dispersity.
+"""
+from __future__ import division, print_function
+
+import argparse
+
+try: # CRUFT: travis-ci does not support mpl_toolkits.mplot3d
+    import mpl_toolkits.mplot3d  # Adds projection='3d' option to subplot
+except ImportError:
+    pass
+
+import matplotlib.pyplot as plt
+from matplotlib.widgets import Slider
+import numpy as np
+from numpy import pi, cos, sin, sqrt, exp, degrees, radians
+
+def draw_beam(axes, view=(0, 0)):
+    """
+    Draw the beam going from source at (0, 0, 1) to detector at (0, 0, -1)
+    """
+    #axes.plot([0,0],[0,0],[1,-1])
+    #axes.scatter([0]*100,[0]*100,np.linspace(1, -1, 100), alpha=0.8)
+
+    steps = 25
+    u = np.linspace(0, 2 * np.pi, steps)
+    v = np.linspace(-1, 1, steps)
+
+    r = 0.02
+    x = r*np.outer(np.cos(u), np.ones_like(v))
+    y = r*np.outer(np.sin(u), np.ones_like(v))
+    z = 1.3*np.outer(np.ones_like(u), v)
+
+    theta, phi = view
+    shape = x.shape
+    points = np.matrix([x.flatten(), y.flatten(), z.flatten()])
+    points = Rz(phi)*Ry(theta)*points
+    x, y, z = [v.reshape(shape) for v in points]
+
+    axes.plot_surface(x, y, z, rstride=4, cstride=4, color='y', alpha=0.5)
+
+def draw_ellipsoid(axes, size, view, jitter, steps=25, alpha=1):
+    """Draw an ellipsoid."""
+    a, b, c = size
+    u = np.linspace(0, 2 * np.pi, steps)
+    v = np.linspace(0, np.pi, steps)
+    x = a*np.outer(np.cos(u), np.sin(v))
+    y = b*np.outer(np.sin(u), np.sin(v))
+    z = c*np.outer(np.ones_like(u), np.cos(v))
+    x, y, z = transform_xyz(view, jitter, x, y, z)
+
+    axes.plot_surface(x, y, z, rstride=4, cstride=4, color='w', alpha=alpha)
+
+    draw_labels(axes, view, jitter, [
+        ('c+', [+0, +0, +c], [+1, +0, +0]),
+        ('c-', [+0, +0, -c], [+0, +0, -1]),
+        ('a+', [+a, +0, +0], [+0, +0, +1]),
+        ('a-', [-a, +0, +0], [+0, +0, -1]),
+        ('b+', [+0, +b, +0], [-1, +0, +0]),
+        ('b-', [+0, -b, +0], [-1, +0, +0]),
+    ])
+
+def draw_sc(axes, size, view, jitter, steps=None, alpha=1):
+    """Draw points for simple cubic paracrystal"""
+    atoms = _build_sc()
+    _draw_crystal(axes, size, view, jitter, atoms=atoms)
+
+def draw_fcc(axes, size, view, jitter, steps=None, alpha=1):
+    """Draw points for face-centered cubic paracrystal"""
+    # Build the simple cubic crystal
+    atoms = _build_sc()
+    # Define the centers for each face
+    # x planes at -1, 0, 1 have four centers per plane, at +/- 0.5 in y and z
+    x, y, z = (
+        [-1]*4 + [0]*4 + [1]*4,
+        ([-0.5]*2 + [0.5]*2)*3,
+        [-0.5, 0.5]*12,
+    )
+    # y and z planes can be generated by substituting x for y and z respectively
+    atoms.extend(zip(x+y+z, y+z+x, z+x+y))
+    _draw_crystal(axes, size, view, jitter, atoms=atoms)
+
+def draw_bcc(axes, size, view, jitter, steps=None, alpha=1):
+    """Draw points for body-centered cubic paracrystal"""
+    # Build the simple cubic crystal
+    atoms = _build_sc()
+    # Define the centers for each octant
+    # x plane at +/- 0.5 have four centers per plane at +/- 0.5 in y and z
+    x, y, z = (
+        [-0.5]*4 + [0.5]*4,
+        ([-0.5]*2 + [0.5]*2)*2,
+        [-0.5, 0.5]*8,
+    )
+    atoms.extend(zip(x, y, z))
+    _draw_crystal(axes, size, view, jitter, atoms=atoms)
+
+def _draw_crystal(axes, size, view, jitter, atoms=None):
+    atoms, size = np.asarray(atoms, 'd').T, np.asarray(size, 'd')
+    x, y, z = atoms*size[:, None]
+    x, y, z = transform_xyz(view, jitter, x, y, z)
+    axes.scatter([x[0]], [y[0]], [z[0]], c='yellow', marker='o')
+    axes.scatter(x[1:], y[1:], z[1:], c='r', marker='o')
+
+def _build_sc():
+    # three planes of 9 dots for x at -1, 0 and 1
+    x, y, z = (
+        [-1]*9 + [0]*9 + [1]*9,
+        ([-1]*3 + [0]*3 + [1]*3)*3,
+        [-1, 0, 1]*9,
+    )
+    atoms = list(zip(x, y, z))
+    #print(list(enumerate(atoms)))
+    # Pull the dot at (0, 0, 1) to the front of the list
+    # It will be highlighted in the view
+    index = 14
+    highlight = atoms[index]
+    del atoms[index]
+    atoms.insert(0, highlight)
+    return atoms
+
+def draw_parallelepiped(axes, size, view, jitter, steps=None, alpha=1):
+    """Draw a parallelepiped."""
+    a, b, c = size
+    x = a*np.array([+1, -1, +1, -1, +1, -1, +1, -1])
+    y = b*np.array([+1, +1, -1, -1, +1, +1, -1, -1])
+    z = c*np.array([+1, +1, +1, +1, -1, -1, -1, -1])
+    tri = np.array([
+        # counter clockwise triangles
+        # z: up/down, x: right/left, y: front/back
+        [0, 1, 2], [3, 2, 1], # top face
+        [6, 5, 4], [5, 6, 7], # bottom face
+        [0, 2, 6], [6, 4, 0], # right face
+        [1, 5, 7], [7, 3, 1], # left face
+        [2, 3, 6], [7, 6, 3], # front face
+        [4, 1, 0], [5, 1, 4], # back face
+    ])
+
+    x, y, z = transform_xyz(view, jitter, x, y, z)
+    axes.plot_trisurf(x, y, triangles=tri, Z=z, color='w', alpha=alpha)
+
+    # Draw pink face on box.
+    # Since I can't control face color, instead draw a thin box situated just
+    # in front of the "c+" face.  Use the c face so that rotations about psi
+    # rotate that face.
+    if 1:
+        x = a*np.array([+1, -1, +1, -1, +1, -1, +1, -1])
+        y = b*np.array([+1, +1, -1, -1, +1, +1, -1, -1])
+        z = c*np.array([+1, +1, +1, +1, -1, -1, -1, -1])
+        x, y, z = transform_xyz(view, jitter, x, y, abs(z)+0.001)
+        axes.plot_trisurf(x, y, triangles=tri, Z=z, color=[1, 0.6, 0.6], alpha=alpha)
+
+    draw_labels(axes, view, jitter, [
+        ('c+', [+0, +0, +c], [+1, +0, +0]),
+        ('c-', [+0, +0, -c], [+0, +0, -1]),
+        ('a+', [+a, +0, +0], [+0, +0, +1]),
+        ('a-', [-a, +0, +0], [+0, +0, -1]),
+        ('b+', [+0, +b, +0], [-1, +0, +0]),
+        ('b-', [+0, -b, +0], [-1, +0, +0]),
+    ])
+
+def draw_sphere(axes, radius=10., steps=100):
+    """Draw a sphere"""
+    u = np.linspace(0, 2 * np.pi, steps)
+    v = np.linspace(0, np.pi, steps)
+
+    x = radius * np.outer(np.cos(u), np.sin(v))
+    y = radius * np.outer(np.sin(u), np.sin(v))
+    z = radius * np.outer(np.ones(np.size(u)), np.cos(v))
+    axes.plot_surface(x, y, z, rstride=4, cstride=4, color='w')
+
+def draw_jitter(axes, view, jitter, dist='gaussian', size=(0.1, 0.4, 1.0),
+                draw_shape=draw_parallelepiped):
+    """
+    Represent jitter as a set of shapes at different orientations.
+    """
+    # set max diagonal to 0.95
+    scale = 0.95/sqrt(sum(v**2 for v in size))
+    size = tuple(scale*v for v in size)
+
+    #np.random.seed(10)
+    #cloud = np.random.randn(10,3)
+    cloud = [
+        [-1, -1, -1],
+        [-1, -1, +0],
+        [-1, -1, +1],
+        [-1, +0, -1],
+        [-1, +0, +0],
+        [-1, +0, +1],
+        [-1, +1, -1],
+        [-1, +1, +0],
+        [-1, +1, +1],
+        [+0, -1, -1],
+        [+0, -1, +0],
+        [+0, -1, +1],
+        [+0, +0, -1],
+        [+0, +0, +0],
+        [+0, +0, +1],
+        [+0, +1, -1],
+        [+0, +1, +0],
+        [+0, +1, +1],
+        [+1, -1, -1],
+        [+1, -1, +0],
+        [+1, -1, +1],
+        [+1, +0, -1],
+        [+1, +0, +0],
+        [+1, +0, +1],
+        [+1, +1, -1],
+        [+1, +1, +0],
+        [+1, +1, +1],
+    ]
+    dtheta, dphi, dpsi = jitter
+    if dtheta == 0:
+        cloud = [v for v in cloud if v[0] == 0]
+    if dphi == 0:
+        cloud = [v for v in cloud if v[1] == 0]
+    if dpsi == 0:
+        cloud = [v for v in cloud if v[2] == 0]
+    draw_shape(axes, size, view, [0, 0, 0], steps=100, alpha=0.8)
+    scale = {'gaussian':1, 'rectangle':1/sqrt(3), 'uniform':1/3}[dist]
+    for point in cloud:
+        delta = [scale*dtheta*point[0], scale*dphi*point[1], scale*dpsi*point[2]]
+        draw_shape(axes, size, view, delta, alpha=0.8)
+    for v in 'xyz':
+        a, b, c = size
+        lim = np.sqrt(a**2 + b**2 + c**2)
+        getattr(axes, 'set_'+v+'lim')([-lim, lim])
+        getattr(axes, v+'axis').label.set_text(v)
+
+PROJECTIONS = [
+    # in order of PROJECTION number; do not change without updating the
+    # constants in kernel_iq.c
+    'equirectangular', 'sinusoidal', 'guyou', 'azimuthal_equidistance',
+    'azimuthal_equal_area',
+]
+def draw_mesh(axes, view, jitter, radius=1.2, n=11, dist='gaussian',
+              projection='equirectangular'):
+    """
+    Draw the dispersion mesh showing the theta-phi orientations at which
+    the model will be evaluated.
+
+    jitter projections
+    <https://en.wikipedia.org/wiki/List_of_map_projections>
+
+    equirectangular (standard latitude-longitude mesh)
+        <https://en.wikipedia.org/wiki/Equirectangular_projection>
+        Allows free movement in phi (around the equator), but theta is
+        limited to +/- 90, and points are cos-weighted. Jitter in phi is
+        uniform in weight along a line of latitude.  With small theta and
+        phi ranging over +/- 180 this forms a wobbling disk.  With small
+        phi and theta ranging over +/- 90 this forms a wedge like a slice
+        of an orange.
+    azimuthal_equidistance (Postel)
+        <https://en.wikipedia.org/wiki/Azimuthal_equidistant_projection>
+        Preserves distance from center, and so is an excellent map for
+        representing a bivariate gaussian on the surface.  Theta and phi
+        operate identically, cutting wegdes from the antipode of the viewing
+        angle.  This unfortunately does not allow free movement in either
+        theta or phi since the orthogonal wobble decreases to 0 as the body
+        rotates through 180 degrees.
+    sinusoidal (Sanson-Flamsteed, Mercator equal-area)
+        <https://en.wikipedia.org/wiki/Sinusoidal_projection>
+        Preserves arc length with latitude, giving bad behaviour at
+        theta near +/- 90.  Theta and phi operate somewhat differently,
+        so a system with a-b-c dtheta-dphi-dpsi will not give the same
+        value as one with b-a-c dphi-dtheta-dpsi, as would be the case
+        for azimuthal equidistance.  Free movement using theta or phi
+        uniform over +/- 180 will work, but not as well as equirectangular
+        phi, with theta being slightly worse.  Computationally it is much
+        cheaper for wide theta-phi meshes since it excludes points which
+        lie outside the sinusoid near theta +/- 90 rather than packing
+        them close together as in equirectangle.  Note that the poles
+        will be slightly overweighted for theta > 90 with the circle
+        from theta at 90+dt winding backwards around the pole, overlapping
+        the circle from theta at 90-dt.
+    Guyou (hemisphere-in-a-square) **not weighted**
+        <https://en.wikipedia.org/wiki/Guyou_hemisphere-in-a-square_projection>
+        With tiling, allows rotation in phi or theta through +/- 180, with
+        uniform spacing.  Both theta and phi allow free rotation, with wobble
+        in the orthogonal direction reasonably well behaved (though not as
+        good as equirectangular phi). The forward/reverse transformations
+        relies on elliptic integrals that are somewhat expensive, so the
+        behaviour has to be very good to justify the cost and complexity.
+        The weighting function for each point has not yet been computed.
+        Note: run the module *guyou.py* directly and it will show the forward
+        and reverse mappings.
+    azimuthal_equal_area  **incomplete**
+        <https://en.wikipedia.org/wiki/Lambert_azimuthal_equal-area_projection>
+        Preserves the relative density of the surface patches.  Not that
+        useful and not completely implemented
+    Gauss-Kreuger **not implemented**
+        <https://en.wikipedia.org/wiki/Transverse_Mercator_projection#Ellipsoidal_transverse_Mercator>
+        Should allow free movement in theta, but phi is distorted.
+    """
+    # TODO: try Kent distribution instead of a gaussian warped by projection
+
+    dist_x = np.linspace(-1, 1, n)
+    weights = np.ones_like(dist_x)
+    if dist == 'gaussian':
+        dist_x *= 3
+        weights = exp(-0.5*dist_x**2)
+    elif dist == 'rectangle':
+        # Note: uses sasmodels ridiculous definition of rectangle width
+        dist_x *= sqrt(3)
+    elif dist == 'uniform':
+        pass
+    else:
+        raise ValueError("expected dist to be gaussian, rectangle or uniform")
+
+    if projection == 'equirectangular':  #define PROJECTION 1
+        def _rotate(theta_i, phi_j):
+            return Rx(phi_j)*Ry(theta_i)
+        def _weight(theta_i, phi_j, w_i, w_j):
+            return w_i*w_j*abs(cos(radians(theta_i)))
+    elif projection == 'sinusoidal':  #define PROJECTION 2
+        def _rotate(theta_i, phi_j):
+            latitude = theta_i
+            scale = cos(radians(latitude))
+            longitude = phi_j/scale if abs(phi_j) < abs(scale)*180 else 0
+            #print("(%+7.2f, %+7.2f) => (%+7.2f, %+7.2f)"%(theta_i, phi_j, latitude, longitude))
+            return Rx(longitude)*Ry(latitude)
+        def _weight(theta_i, phi_j, w_i, w_j):
+            latitude = theta_i
+            scale = cos(radians(latitude))
+            active = 1 if abs(phi_j) < abs(scale)*180 else 0
+            return active*w_i*w_j
+    elif projection == 'guyou':  #define PROJECTION 3  (eventually?)
+        def _rotate(theta_i, phi_j):
+            from .guyou import guyou_invert
+            #latitude, longitude = guyou_invert([theta_i], [phi_j])
+            longitude, latitude = guyou_invert([phi_j], [theta_i])
+            return Rx(longitude[0])*Ry(latitude[0])
+        def _weight(theta_i, phi_j, w_i, w_j):
+            return w_i*w_j
+    elif projection == 'azimuthal_equidistance':  # Note: Rz Ry, not Rx Ry
+        def _rotate(theta_i, phi_j):
+            latitude = sqrt(theta_i**2 + phi_j**2)
+            longitude = degrees(np.arctan2(phi_j, theta_i))
+            #print("(%+7.2f, %+7.2f) => (%+7.2f, %+7.2f)"%(theta_i, phi_j, latitude, longitude))
+            return Rz(longitude)*Ry(latitude)
+        def _weight(theta_i, phi_j, w_i, w_j):
+            # Weighting for each point comes from the integral:
+            #     \int\int I(q, lat, log) sin(lat) dlat dlog
+            # We are doing a conformal mapping from disk to sphere, so we need
+            # a change of variables g(theta, phi) -> (lat, long):
+            #     lat, long = sqrt(theta^2 + phi^2), arctan(phi/theta)
+            # giving:
+            #     dtheta dphi = det(J) dlat dlong
+            # where J is the jacobian from the partials of g. Using
+            #     R = sqrt(theta^2 + phi^2),
+            # then
+            #     J = [[x/R, Y/R], -y/R^2, x/R^2]]
+            # and
+            #     det(J) = 1/R
+            # with the final integral being:
+            #    \int\int I(q, theta, phi) sin(R)/R dtheta dphi
+            #
+            # This does approximately the right thing, decreasing the weight
+            # of each point as you go farther out on the disk, but it hasn't
+            # yet been checked against the 1D integral results. Prior
+            # to declaring this "good enough" and checking that integrals
+            # work in practice, we will examine alternative mappings.
+            #
+            # The issue is that the mapping does not support the case of free
+            # rotation about a single axis correctly, with a small deviation
+            # in the orthogonal axis independent of the first axis.  Like the
+            # usual polar coordiates integration, the integrated sections
+            # form wedges, though at least in this case the wedge cuts through
+            # the entire sphere, and treats theta and phi identically.
+            latitude = sqrt(theta_i**2 + phi_j**2)
+            weight = sin(radians(latitude))/latitude if latitude != 0 else 1
+            return weight*w_i*w_j if latitude < 180 else 0
+    elif projection == 'azimuthal_equal_area':
+        def _rotate(theta_i, phi_j):
+            radius = min(1, sqrt(theta_i**2 + phi_j**2)/180)
+            latitude = 180-degrees(2*np.arccos(radius))
+            longitude = degrees(np.arctan2(phi_j, theta_i))
+            #print("(%+7.2f, %+7.2f) => (%+7.2f, %+7.2f)"%(theta_i, phi_j, latitude, longitude))
+            return Rz(longitude)*Ry(latitude)
+        def _weight(theta_i, phi_j, w_i, w_j):
+            latitude = sqrt(theta_i**2 + phi_j**2)
+            weight = sin(radians(latitude))/latitude if latitude != 0 else 1
+            return weight*w_i*w_j if latitude < 180 else 0
+    else:
+        raise ValueError("unknown projection %r"%projection)
+
+    # mesh in theta, phi formed by rotating z
+    dtheta, dphi, dpsi = jitter
+    z = np.matrix([[0], [0], [radius]])
+    points = np.hstack([_rotate(theta_i, phi_j)*z
+                        for theta_i in dtheta*dist_x
+                        for phi_j in dphi*dist_x])
+    dist_w = np.array([_weight(theta_i, phi_j, w_i, w_j)
+                       for w_i, theta_i in zip(weights, dtheta*dist_x)
+                       for w_j, phi_j in zip(weights, dphi*dist_x)])
+    #print(max(dist_w), min(dist_w), min(dist_w[dist_w > 0]))
+    points = points[:, dist_w > 0]
+    dist_w = dist_w[dist_w > 0]
+    dist_w /= max(dist_w)
+
+    # rotate relative to beam
+    points = orient_relative_to_beam(view, points)
+
+    x, y, z = [np.array(v).flatten() for v in points]
+    #plt.figure(2); plt.clf(); plt.hist(z, bins=np.linspace(-1, 1, 51))
+    axes.scatter(x, y, z, c=dist_w, marker='o', vmin=0., vmax=1.)
+
+def draw_labels(axes, view, jitter, text):
+    """
+    Draw text at a particular location.
+    """
+    labels, locations, orientations = zip(*text)
+    px, py, pz = zip(*locations)
+    dx, dy, dz = zip(*orientations)
+
+    px, py, pz = transform_xyz(view, jitter, px, py, pz)
+    dx, dy, dz = transform_xyz(view, jitter, dx, dy, dz)
+
+    # TODO: zdir for labels is broken, and labels aren't appearing.
+    for label, p, zdir in zip(labels, zip(px, py, pz), zip(dx, dy, dz)):
+        zdir = np.asarray(zdir).flatten()
+        axes.text(p[0], p[1], p[2], label, zdir=zdir)
+
+# Definition of rotation matrices comes from wikipedia:
+#    https://en.wikipedia.org/wiki/Rotation_matrix#Basic_rotations
+def Rx(angle):
+    """Construct a matrix to rotate points about *x* by *angle* degrees."""
+    angle = radians(angle)
+    rot = [[1, 0, 0],
+           [0, +cos(angle), -sin(angle)],
+           [0, +sin(angle), +cos(angle)]]
+    return np.matrix(rot)
+
+def Ry(angle):
+    """Construct a matrix to rotate points about *y* by *angle* degrees."""
+    angle = radians(angle)
+    rot = [[+cos(angle), 0, +sin(angle)],
+           [0, 1, 0],
+           [-sin(angle), 0, +cos(angle)]]
+    return np.matrix(rot)
+
+def Rz(angle):
+    """Construct a matrix to rotate points about *z* by *angle* degrees."""
+    angle = radians(angle)
+    rot = [[+cos(angle), -sin(angle), 0],
+           [+sin(angle), +cos(angle), 0],
+           [0, 0, 1]]
+    return np.matrix(rot)
+
+def transform_xyz(view, jitter, x, y, z):
+    """
+    Send a set of (x,y,z) points through the jitter and view transforms.
+    """
+    x, y, z = [np.asarray(v) for v in (x, y, z)]
+    shape = x.shape
+    points = np.matrix([x.flatten(), y.flatten(), z.flatten()])
+    points = apply_jitter(jitter, points)
+    points = orient_relative_to_beam(view, points)
+    x, y, z = [np.array(v).reshape(shape) for v in points]
+    return x, y, z
+
+def apply_jitter(jitter, points):
+    """
+    Apply the jitter transform to a set of points.
+
+    Points are stored in a 3 x n numpy matrix, not a numpy array or tuple.
+    """
+    dtheta, dphi, dpsi = jitter
+    points = Rx(dphi)*Ry(dtheta)*Rz(dpsi)*points
+    return points
+
+def orient_relative_to_beam(view, points):
+    """
+    Apply the view transform to a set of points.
+
+    Points are stored in a 3 x n numpy matrix, not a numpy array or tuple.
+    """
+    theta, phi, psi = view
+    points = Rz(phi)*Ry(theta)*Rz(psi)*points
+    return points
+
+# translate between number of dimension of dispersity and the number of
+# points along each dimension.
+PD_N_TABLE = {
+    (0, 0, 0): (0, 0, 0),     # 0
+    (1, 0, 0): (100, 0, 0),   # 100
+    (0, 1, 0): (0, 100, 0),
+    (0, 0, 1): (0, 0, 100),
+    (1, 1, 0): (30, 30, 0),   # 900
+    (1, 0, 1): (30, 0, 30),
+    (0, 1, 1): (0, 30, 30),
+    (1, 1, 1): (15, 15, 15),  # 3375
+}
+
+def clipped_range(data, portion=1.0, mode='central'):
+    """
+    Determine range from data.
+
+    If *portion* is 1, use full range, otherwise use the center of the range
+    or the top of the range, depending on whether *mode* is 'central' or 'top'.
+    """
+    if portion == 1.0:
+        return data.min(), data.max()
+    elif mode == 'central':
+        data = np.sort(data.flatten())
+        offset = int(portion*len(data)/2 + 0.5)
+        return data[offset], data[-offset]
+    elif mode == 'top':
+        data = np.sort(data.flatten())
+        offset = int(portion*len(data) + 0.5)
+        return data[offset], data[-1]
+
+def draw_scattering(calculator, axes, view, jitter, dist='gaussian'):
+    """
+    Plot the scattering for the particular view.
+
+    *calculator* is returned from :func:`build_model`.  *axes* are the 3D axes
+    on which the data will be plotted.  *view* and *jitter* are the current
+    orientation and orientation dispersity.  *dist* is one of the sasmodels
+    weight distributions.
+    """
+    if dist == 'uniform':  # uniform is not yet in this branch
+        dist, scale = 'rectangle', 1/sqrt(3)
+    else:
+        scale = 1
+
+    # add the orientation parameters to the model parameters
+    theta, phi, psi = view
+    theta_pd, phi_pd, psi_pd = [scale*v for v in jitter]
+    theta_pd_n, phi_pd_n, psi_pd_n = PD_N_TABLE[(theta_pd > 0, phi_pd > 0, psi_pd > 0)]
+    ## increase pd_n for testing jitter integration rather than simple viz
+    #theta_pd_n, phi_pd_n, psi_pd_n = [5*v for v in (theta_pd_n, phi_pd_n, psi_pd_n)]
+
+    pars = dict(
+        theta=theta, theta_pd=theta_pd, theta_pd_type=dist, theta_pd_n=theta_pd_n,
+        phi=phi, phi_pd=phi_pd, phi_pd_type=dist, phi_pd_n=phi_pd_n,
+        psi=psi, psi_pd=psi_pd, psi_pd_type=dist, psi_pd_n=psi_pd_n,
+    )
+    pars.update(calculator.pars)
+
+    # compute the pattern
+    qx, qy = calculator._data.x_bins, calculator._data.y_bins
+    Iqxy = calculator(**pars).reshape(len(qx), len(qy))
+
+    # scale it and draw it
+    Iqxy = np.log(Iqxy)
+    if calculator.limits:
+        # use limits from orientation (0,0,0)
+        vmin, vmax = calculator.limits
+    else:
+        vmax = Iqxy.max()
+        vmin = vmax*10**-7
+        #vmin, vmax = clipped_range(Iqxy, portion=portion, mode='top')
+    #print("range",(vmin,vmax))
+    #qx, qy = np.meshgrid(qx, qy)
+    if 0:
+        level = np.asarray(255*(Iqxy - vmin)/(vmax - vmin), 'i')
+        level[level < 0] = 0
+        colors = plt.get_cmap()(level)
+        axes.plot_surface(qx, qy, -1.1, rstride=1, cstride=1, facecolors=colors)
+    elif 1:
+        axes.contourf(qx/qx.max(), qy/qy.max(), Iqxy, zdir='z', offset=-1.1,
+                      levels=np.linspace(vmin, vmax, 24))
+    else:
+        axes.pcolormesh(qx, qy, Iqxy)
+
+def build_model(model_name, n=150, qmax=0.5, **pars):
+    """
+    Build a calculator for the given shape.
+
+    *model_name* is any sasmodels model.  *n* and *qmax* define an n x n mesh
+    on which to evaluate the model.  The remaining parameters are stored in
+    the returned calculator as *calculator.pars*.  They are used by
+    :func:`draw_scattering` to set the non-orientation parameters in the
+    calculation.
+
+    Returns a *calculator* function which takes a dictionary or parameters and
+    produces Iqxy.  The Iqxy value needs to be reshaped to an n x n matrix
+    for plotting.  See the :class:`sasmodels.direct_model.DirectModel` class
+    for details.
+    """
+    from sasmodels.core import load_model_info, build_model as build_sasmodel
+    from sasmodels.data import empty_data2D
+    from sasmodels.direct_model import DirectModel
+
+    model_info = load_model_info(model_name)
+    model = build_sasmodel(model_info) #, dtype='double!')
+    q = np.linspace(-qmax, qmax, n)
+    data = empty_data2D(q, q)
+    calculator = DirectModel(data, model)
+
+    # stuff the values for non-orientation parameters into the calculator
+    calculator.pars = pars.copy()
+    calculator.pars.setdefault('backgound', 1e-3)
+
+    # fix the data limits so that we can see if the pattern fades
+    # under rotation or angular dispersion
+    Iqxy = calculator(theta=0, phi=0, psi=0, **calculator.pars)
+    Iqxy = np.log(Iqxy)
+    vmin, vmax = clipped_range(Iqxy, 0.95, mode='top')
+    calculator.limits = vmin, vmax+1
+
+    return calculator
+
+def select_calculator(model_name, n=150, size=(10, 40, 100)):
+    """
+    Create a model calculator for the given shape.
+
+    *model_name* is one of sphere, cylinder, ellipsoid, triaxial_ellipsoid,
+    parallelepiped or bcc_paracrystal. *n* is the number of points to use
+    in the q range.  *qmax* is chosen based on model parameters for the
+    given model to show something intersting.
+
+    Returns *calculator* and tuple *size* (a,b,c) giving minor and major
+    equitorial axes and polar axis respectively.  See :func:`build_model`
+    for details on the returned calculator.
+    """
+    a, b, c = size
+    d_factor = 0.06  # for paracrystal models
+    if model_name == 'sphere':
+        calculator = build_model('sphere', n=n, radius=c)
+        a = b = c
+    elif model_name == 'sc_paracrystal':
+        a = b = c
+        dnn = c
+        radius = 0.5*c
+        calculator = build_model('sc_paracrystal', n=n, dnn=dnn,
+                                 d_factor=d_factor, radius=(1-d_factor)*radius,
+                                 background=0)
+    elif model_name == 'fcc_paracrystal':
+        a = b = c
+        # nearest neigbour distance dnn should be 2 radius, but I think the
+        # model uses lattice spacing rather than dnn in its calculations
+        dnn = 0.5*c
+        radius = sqrt(2)/4 * c
+        calculator = build_model('fcc_paracrystal', n=n, dnn=dnn,
+                                 d_factor=d_factor, radius=(1-d_factor)*radius,
+                                 background=0)
+    elif model_name == 'bcc_paracrystal':
+        a = b = c
+        # nearest neigbour distance dnn should be 2 radius, but I think the
+        # model uses lattice spacing rather than dnn in its calculations
+        dnn = 0.5*c
+        radius = sqrt(3)/2 * c
+        calculator = build_model('bcc_paracrystal', n=n, dnn=dnn,
+                                 d_factor=d_factor, radius=(1-d_factor)*radius,
+                                 background=0)
+    elif model_name == 'cylinder':
+        calculator = build_model('cylinder', n=n, qmax=0.3, radius=b, length=c)
+        a = b
+    elif model_name == 'ellipsoid':
+        calculator = build_model('ellipsoid', n=n, qmax=1.0,
+                                 radius_polar=c, radius_equatorial=b)
+        a = b
+    elif model_name == 'triaxial_ellipsoid':
+        calculator = build_model('triaxial_ellipsoid', n=n, qmax=0.5,
+                                 radius_equat_minor=a,
+                                 radius_equat_major=b,
+                                 radius_polar=c)
+    elif model_name == 'parallelepiped':
+        calculator = build_model('parallelepiped', n=n, a=a, b=b, c=c)
+    else:
+        raise ValueError("unknown model %s"%model_name)
+
+    return calculator, (a, b, c)
+
+SHAPES = [
+    'parallelepiped',
+    'sphere', 'ellipsoid', 'triaxial_ellipsoid',
+    'cylinder',
+    'fcc_paracrystal', 'bcc_paracrystal', 'sc_paracrystal',
+]
+
+DRAW_SHAPES = {
+    'fcc_paracrystal': draw_fcc,
+    'bcc_paracrystal': draw_bcc,
+    'sc_paracrystal': draw_sc,
+    'parallelepiped': draw_parallelepiped,
+}
+
+DISTRIBUTIONS = [
+    'gaussian', 'rectangle', 'uniform',
+]
+DIST_LIMITS = {
+    'gaussian': 30,
+    'rectangle': 90/sqrt(3),
+    'uniform': 90,
+}
+
+def run(model_name='parallelepiped', size=(10, 40, 100),
+        dist='gaussian', mesh=30,
+        projection='equirectangular'):
+    """
+    Show an interactive orientation and jitter demo.
+
+    *model_name* is one of: sphere, ellipsoid, triaxial_ellipsoid,
+    parallelepiped, cylinder, or sc/fcc/bcc_paracrystal
+
+    *size* gives the dimensions (a, b, c) of the shape.
+
+    *dist* is the type of dispersition: gaussian, rectangle, or uniform.
+
+    *mesh* is the number of points in the dispersion mesh.
+
+    *projection* is the map projection to use for the mesh: equirectangular,
+    sinusoidal, guyou, azimuthal_equidistance, or azimuthal_equal_area.
+    """
+    # projection number according to 1-order position in list, but
+    # only 1 and 2 are implemented so far.
+    from sasmodels import generate
+    generate.PROJECTION = PROJECTIONS.index(projection) + 1
+    if generate.PROJECTION > 2:
+        print("*** PROJECTION %s not implemented in scattering function ***"%projection)
+        generate.PROJECTION = 2
+
+    # set up calculator
+    calculator, size = select_calculator(model_name, n=150, size=size)
+    draw_shape = DRAW_SHAPES.get(model_name, draw_parallelepiped)
+
+    ## uncomment to set an independent the colour range for every view
+    ## If left commented, the colour range is fixed for all views
+    calculator.limits = None
+
+    ## initial view
+    #theta, dtheta = 70., 10.
+    #phi, dphi = -45., 3.
+    #psi, dpsi = -45., 3.
+    theta, phi, psi = 0, 0, 0
+    dtheta, dphi, dpsi = 0, 0, 0
+
+    ## create the plot window
+    #plt.hold(True)
+    plt.subplots(num=None, figsize=(5.5, 5.5))
+    plt.set_cmap('gist_earth')
+    plt.clf()
+    plt.gcf().canvas.set_window_title(projection)
+    #gs = gridspec.GridSpec(2,1,height_ratios=[4,1])
+    #axes = plt.subplot(gs[0], projection='3d')
+    axes = plt.axes([0.0, 0.2, 1.0, 0.8], projection='3d')
+    try:  # CRUFT: not all versions of matplotlib accept 'square' 3d projection
+        axes.axis('square')
+    except Exception:
+        pass
+
+    axcolor = 'lightgoldenrodyellow'
+
+    ## add control widgets to plot
+    axes_theta = plt.axes([0.1, 0.15, 0.45, 0.04], axisbg=axcolor)
+    axes_phi = plt.axes([0.1, 0.1, 0.45, 0.04], axisbg=axcolor)
+    axes_psi = plt.axes([0.1, 0.05, 0.45, 0.04], axisbg=axcolor)
+    stheta = Slider(axes_theta, 'Theta', -90, 90, valinit=theta)
+    sphi = Slider(axes_phi, 'Phi', -180, 180, valinit=phi)
+    spsi = Slider(axes_psi, 'Psi', -180, 180, valinit=psi)
+
+    axes_dtheta = plt.axes([0.75, 0.15, 0.15, 0.04], axisbg=axcolor)
+    axes_dphi = plt.axes([0.75, 0.1, 0.15, 0.04], axisbg=axcolor)
+    axes_dpsi = plt.axes([0.75, 0.05, 0.15, 0.04], axisbg=axcolor)
+    # Note: using ridiculous definition of rectangle distribution, whose width
+    # in sasmodels is sqrt(3) times the given width.  Divide by sqrt(3) to keep
+    # the maximum width to 90.
+    dlimit = DIST_LIMITS[dist]
+    sdtheta = Slider(axes_dtheta, 'dTheta', 0, 2*dlimit, valinit=dtheta)
+    sdphi = Slider(axes_dphi, 'dPhi', 0, 2*dlimit, valinit=dphi)
+    sdpsi = Slider(axes_dpsi, 'dPsi', 0, 2*dlimit, valinit=dpsi)
+
+
+    ## callback to draw the new view
+    def update(val, axis=None):
+        view = stheta.val, sphi.val, spsi.val
+        jitter = sdtheta.val, sdphi.val, sdpsi.val
+        # set small jitter as 0 if multiple pd dims
+        dims = sum(v > 0 for v in jitter)
+        limit = [0, 0, 0.5, 5][dims]
+        jitter = [0 if v < limit else v for v in jitter]
+        axes.cla()
+        draw_beam(axes, (0, 0))
+        draw_jitter(axes, view, jitter, dist=dist, size=size, draw_shape=draw_shape)
+        #draw_jitter(axes, view, (0,0,0))
+        draw_mesh(axes, view, jitter, dist=dist, n=mesh, projection=projection)
+        draw_scattering(calculator, axes, view, jitter, dist=dist)
+        plt.gcf().canvas.draw()
+
+    ## bind control widgets to view updater
+    stheta.on_changed(lambda v: update(v, 'theta'))
+    sphi.on_changed(lambda v: update(v, 'phi'))
+    spsi.on_changed(lambda v: update(v, 'psi'))
+    sdtheta.on_changed(lambda v: update(v, 'dtheta'))
+    sdphi.on_changed(lambda v: update(v, 'dphi'))
+    sdpsi.on_changed(lambda v: update(v, 'dpsi'))
+
+    ## initialize view
+    update(None, 'phi')
+
+    ## go interactive
+    plt.show()
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Display jitter",
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+        )
+    parser.add_argument('-p', '--projection', choices=PROJECTIONS,
+                        default=PROJECTIONS[0],
+                        help='coordinate projection')
+    parser.add_argument('-s', '--size', type=str, default='10,40,100',
+                        help='a,b,c lengths')
+    parser.add_argument('-d', '--distribution', choices=DISTRIBUTIONS,
+                        default=DISTRIBUTIONS[0],
+                        help='jitter distribution')
+    parser.add_argument('-m', '--mesh', type=int, default=30,
+                        help='#points in theta-phi mesh')
+    parser.add_argument('shape', choices=SHAPES, nargs='?', default=SHAPES[0],
+                        help='oriented shape')
+    opts = parser.parse_args()
+    size = tuple(int(v) for v in opts.size.split(','))
+    run(opts.shape, size=size,
+        mesh=opts.mesh, dist=opts.distribution,
+        projection=opts.projection)
+
+if __name__ == "__main__":
+    main()
Index: sasmodels/kernel_iq.c
===================================================================
--- sasmodels/kernel_iq.c	(revision ec8d4acf126bf6a5404bc968d4eea25959657b12)
+++ sasmodels/kernel_iq.c	(revision dc6f601ca60f7d630a484562e056a94ed48bae82)
@@ -79,19 +79,22 @@
 //     du * (m_sigma_y + 1j*m_sigma_z);
 // weights for spin crosssections: dd du real, ud real, uu, du imag, ud imag
-static void set_spin_weights(double in_spin, double out_spin, double spins[4])
+static void set_spin_weights(double in_spin, double out_spin, double weight[6])
 {
   in_spin = clip(in_spin, 0.0, 1.0);
   out_spin = clip(out_spin, 0.0, 1.0);
-  spins[0] = sqrt(sqrt((1.0-in_spin) * (1.0-out_spin))); // dd
-  spins[1] = sqrt(sqrt((1.0-in_spin) * out_spin));       // du real
-  spins[2] = sqrt(sqrt(in_spin * (1.0-out_spin)));       // ud real
-  spins[3] = sqrt(sqrt(in_spin * out_spin));             // uu
-  spins[4] = spins[1]; // du imag
-  spins[5] = spins[2]; // ud imag
+  // Note: sasview 3.1 scaled all slds by sqrt(weight) and assumed that
+  //     w*I(q, rho1, rho2, ...) = I(q, sqrt(w)*rho1, sqrt(w)*rho2, ...)
+  // which is likely to be the case for simple models.
+  weight[0] = sqrt((1.0-in_spin) * (1.0-out_spin)); // dd
+  weight[1] = sqrt((1.0-in_spin) * out_spin);       // du.real
+  weight[2] = sqrt(in_spin * (1.0-out_spin));       // ud.real
+  weight[3] = sqrt(in_spin * out_spin);             // uu
+  weight[4] = weight[1]; // du.imag
+  weight[5] = weight[2]; // ud.imag
 }
 
 // Compute the magnetic sld
 static double mag_sld(
-  const int xs, // 0=dd, 1=du real, 2=ud real, 3=uu, 4=du imag, 5=up imag
+  const unsigned int xs, // 0=dd, 1=du.real, 2=ud.real, 3=uu, 4=du.imag, 5=ud.imag
   const double qx, const double qy,
   const double px, const double py,
@@ -103,18 +106,19 @@
     const double perp = qy*mx - qx*my;
     switch (xs) {
+      default: // keep compiler happy; condition ensures xs in [0,1,2,3]
       case 0: // uu => sld - D M_perpx
           return sld - px*perp;
-      case 1: // ud real => -D M_perpy
+      case 1: // ud.real => -D M_perpy
           return py*perp;
-      case 2: // du real => -D M_perpy
+      case 2: // du.real => -D M_perpy
           return py*perp;
-      case 3: // dd real => sld + D M_perpx
+      case 3: // dd => sld + D M_perpx
           return sld + px*perp;
     }
   } else {
     if (xs== 4) {
-      return -mz;  // ud imag => -D M_perpz
+      return -mz;  // du.imag => +D M_perpz
     } else { // index == 5
-      return mz;   // du imag => D M_perpz
+      return +mz;  // ud.imag => -D M_perpz
     }
   }
@@ -172,5 +176,5 @@
 // Apply the rotation matrix returned from qac_rotation to the point (qx,qy),
 // returning R*[qx,qy]' = [qa,qc]'
-static double
+static void
 qac_apply(
     QACRotation *rotation,
@@ -245,5 +249,5 @@
 // Apply the rotation matrix returned from qabc_rotation to the point (qx,qy),
 // returning R*[qx,qy]' = [qa,qb,qc]'
-static double
+static void
 qabc_apply(
     QABCRotation *rotation,
@@ -311,7 +315,7 @@
   //     up_angle = values[NUM_PARS+4];
   // TODO: could precompute more magnetism parameters before calling the kernel.
-  double spins[8];  // uu, ud real, du real, dd, ud imag, du imag, fill, fill
+  double xs_weights[8];  // uu, ud real, du real, dd, ud imag, du imag, fill, fill
   double cos_mspin, sin_mspin;
-  set_spin_weights(values[NUM_PARS+2], values[NUM_PARS+3], spins);
+  set_spin_weights(values[NUM_PARS+2], values[NUM_PARS+3], xs_weights);
   SINCOS(-values[NUM_PARS+4]*M_PI_180, sin_mspin, cos_mspin);
 #endif // MAGNETIC
@@ -659,6 +663,6 @@
 
             // loop over uu, ud real, du real, dd, ud imag, du imag
-            for (int xs=0; xs<6; xs++) {
-              const double xs_weight = spins[xs];
+            for (unsigned int xs=0; xs<6; xs++) {
+              const double xs_weight = xs_weights[xs];
               if (xs_weight > 1.e-8) {
                 // Since the cross section weight is significant, set the slds
@@ -673,4 +677,6 @@
                   local_values.vector[sld_index] =
                     mag_sld(xs, qx, qy, px, py, values[sld_index+2], mx, my, mz);
+//if (q_index==0) printf("%d: (qx,qy)=(%g,%g) xs=%d sld%d=%g p=(%g,%g) m=(%g,%g,%g)\n",
+//  q_index, qx, qy, xs, sk, local_values.vector[sld_index], px, py, mx, my, mz);
                 }
                 scattering += xs_weight * CALL_KERNEL();
Index: sasmodels/kernelcl.py
===================================================================
--- sasmodels/kernelcl.py	(revision 2d81cfe3be42579d563407fb9275b7b516fee234)
+++ sasmodels/kernelcl.py	(revision d86f0fc0fff835c5ddf398e64c046518fa4536fc)
@@ -58,16 +58,18 @@
 import numpy as np  # type: ignore
 
+
+# Attempt to setup opencl. This may fail if the opencl package is not
+# installed or if it is installed but there are no devices available.
 try:
-    #raise NotImplementedError("OpenCL not yet implemented for new kernel template")
     import pyopencl as cl  # type: ignore
+    from pyopencl import mem_flags as mf
+    from pyopencl.characterize import get_fast_inaccurate_build_options
     # Ask OpenCL for the default context so that we know that one exists
     cl.create_some_context(interactive=False)
+    HAVE_OPENCL = True
+    OPENCL_ERROR = ""
 except Exception as exc:
-    warnings.warn("OpenCL startup failed with ***"
-                  + str(exc) + "***; using C compiler instead")
-    raise RuntimeError("OpenCL not available")
-
-from pyopencl import mem_flags as mf
-from pyopencl.characterize import get_fast_inaccurate_build_options
+    HAVE_OPENCL = False
+    OPENCL_ERROR = str(exc)
 
 from . import generate
@@ -102,6 +104,6 @@
         cl._DEFAULT_INCLUDE_OPTIONS = [quote_path(v) for v in cl._DEFAULT_INCLUDE_OPTIONS]
 
-fix_pyopencl_include()
-
+if HAVE_OPENCL:
+    fix_pyopencl_include()
 
 # The max loops number is limited by the amount of local memory available
@@ -128,6 +130,15 @@
 """
 
+def use_opencl():
+    return HAVE_OPENCL and os.environ.get("SAS_OPENCL", "").lower() != "none"
 
 ENV = None
+def reset_environment():
+    """
+    Call to create a new OpenCL context, such as after a change to SAS_OPENCL.
+    """
+    global ENV
+    ENV = GpuEnvironment() if use_opencl() else None
+
 def environment():
     # type: () -> "GpuEnvironment"
@@ -137,7 +148,11 @@
     This provides an OpenCL context and one queue per device.
     """
-    global ENV
     if ENV is None:
-        ENV = GpuEnvironment()
+        if not HAVE_OPENCL:
+            raise RuntimeError("OpenCL startup failed with ***"
+                               + OPENCL_ERROR + "***; using C compiler instead")
+        reset_environment()
+        if ENV is None:
+            raise RuntimeError("SAS_OPENCL=None in environment")
     return ENV
 
@@ -193,7 +208,7 @@
     Build a model to run on the gpu.
 
-    Returns the compiled program and its type.  The returned type will
-    be float32 even if the desired type is float64 if any of the
-    devices in the context do not support the cl_khr_fp64 extension.
+    Returns the compiled program and its type.
+
+    Raises an error if the desired precision is not available.
     """
     dtype = np.dtype(dtype)
Index: sasmodels/kerneldll.py
===================================================================
--- sasmodels/kerneldll.py	(revision 1ddb794c41646e0529a2f0ed64f654f04e7364c8)
+++ sasmodels/kerneldll.py	(revision 33969b656596e8b6cc8ce934cd1f8062f7b11cf2)
@@ -123,5 +123,8 @@
     # add openmp support if not running on a mac
     if sys.platform != "darwin":
-        CC.append("-fopenmp")
+        # OpenMP seems to be broken on gcc 5.4.0 (ubuntu 16.04.9)
+        # Shut it off for all unix until we can investigate.
+        #CC.append("-fopenmp")
+        pass
     def compile_command(source, output):
         """unix compiler command"""
@@ -158,13 +161,6 @@
         return CC + [source, "-o", output, "-lm"]
 
-# Windows-specific solution
-if os.name == 'nt':
-    # Assume the default location of module DLLs is in .sasmodels/compiled_models.
-    DLL_PATH = os.path.join(os.path.expanduser("~"), ".sasmodels", "compiled_models")
-    if not os.path.exists(DLL_PATH):
-        os.makedirs(DLL_PATH)
-else:
-    # Set up the default path for compiled modules.
-    DLL_PATH = tempfile.gettempdir()
+# Assume the default location of module DLLs is in .sasmodels/compiled_models.
+DLL_PATH = os.path.join(os.path.expanduser("~"), ".sasmodels", "compiled_models")
 
 ALLOW_SINGLE_PRECISION_DLLS = True
@@ -234,5 +230,5 @@
 
     Set *sasmodels.kerneldll.DLL_PATH* to the compiled dll output path.
-    The default is the system temporary directory.
+    The default is in ~/.sasmodels/compiled_models.
     """
     if dtype == F16:
@@ -251,4 +247,7 @@
         need_recompile = dll_time < newest_source
     if need_recompile:
+        # Make sure the DLL path exists
+        if not os.path.exists(DLL_PATH):
+            os.makedirs(DLL_PATH)
         basename = splitext(os.path.basename(dll))[0] + "_"
         system_fd, filename = tempfile.mkstemp(suffix=".c", prefix=basename)
Index: sasmodels/mixture.py
===================================================================
--- sasmodels/mixture.py	(revision 2d81cfe3be42579d563407fb9275b7b516fee234)
+++ sasmodels/mixture.py	(revision ecf895e58d2474f33943e2931dfdca4633a22dc8)
@@ -94,8 +94,8 @@
             # If model is a sum model, each constituent model gets its own scale parameter
             scale_prefix = prefix
-            if prefix == '' and part.operation == '*':
+            if prefix == '' and getattr(part, "operation", '') == '*':
                 # `part` is a composition product model. Find the prefixes of
-                # it's parameters to form a new prefix for the scale, eg:
-                # a model with A*B*C will have ABC_scale
+                # it's parameters to form a new prefix for the scale.
+                # For example, a model with A*B*C will have ABC_scale.
                 sub_prefixes = []
                 for param in part.parameters.kernel_parameters:
@@ -233,5 +233,5 @@
         return self
 
-    def next(self):
+    def __next__(self):
         # type: () -> Tuple[List[Callable], CallDetails, np.ndarray]
         if self.part_num >= len(self.parts):
@@ -251,4 +251,7 @@
 
         return kernel, call_details, values
+
+    # CRUFT: py2 support
+    next = __next__
 
     def _part_details(self, info, par_index):
Index: sasmodels/model_test.py
===================================================================
--- sasmodels/model_test.py	(revision 2d81cfe3be42579d563407fb9275b7b516fee234)
+++ sasmodels/model_test.py	(revision 3221de0ed9b3480163246ceb0503432126bd5134)
@@ -62,4 +62,5 @@
 from .exception import annotate_exception
 from .modelinfo import expand_pars
+from .kernelcl import use_opencl
 
 # pylint: disable=unused-import
@@ -123,5 +124,5 @@
 
         if is_py:  # kernel implemented in python
-            test_name = "Model: %s, Kernel: python"%model_name
+            test_name = "%s-python"%model_name
             test_method_name = "test_%s_python" % model_info.id
             test = ModelTestCase(test_name, model_info,
@@ -134,6 +135,6 @@
 
             # test using dll if desired
-            if 'dll' in loaders or not core.HAVE_OPENCL:
-                test_name = "Model: %s, Kernel: dll"%model_name
+            if 'dll' in loaders or not use_opencl():
+                test_name = "%s-dll"%model_name
                 test_method_name = "test_%s_dll" % model_info.id
                 test = ModelTestCase(test_name, model_info,
@@ -145,6 +146,6 @@
 
             # test using opencl if desired and available
-            if 'opencl' in loaders and core.HAVE_OPENCL:
-                test_name = "Model: %s, Kernel: OpenCL"%model_name
+            if 'opencl' in loaders and use_opencl():
+                test_name = "%s-opencl"%model_name
                 test_method_name = "test_%s_opencl" % model_info.id
                 # Using dtype=None so that the models that are only
@@ -160,5 +161,4 @@
 
     return suite
-
 
 def _hide_model_case_from_nose():
@@ -368,5 +368,5 @@
 
     # Build a test suite containing just the model
-    loaders = ['opencl'] if core.HAVE_OPENCL else ['dll']
+    loaders = ['opencl'] if use_opencl() else ['dll']
     models = [model]
     try:
@@ -425,5 +425,5 @@
         verbosity = 1
     if models and models[0] == 'opencl':
-        if not core.HAVE_OPENCL:
+        if not use_opencl():
             print("opencl is not available")
             return 1
@@ -435,8 +435,8 @@
         models = models[1:]
     elif models and models[0] == 'opencl_and_dll':
-        loaders = ['opencl', 'dll'] if core.HAVE_OPENCL else ['dll']
+        loaders = ['opencl', 'dll'] if use_opencl() else ['dll']
         models = models[1:]
     else:
-        loaders = ['opencl', 'dll'] if core.HAVE_OPENCL else ['dll']
+        loaders = ['opencl', 'dll'] if use_opencl() else ['dll']
     if not models:
         print("""\
@@ -467,15 +467,25 @@
     Run "nosetests sasmodels" on the command line to invoke it.
     """
-    loaders = ['opencl', 'dll'] if core.HAVE_OPENCL else ['dll']
+    loaders = ['opencl', 'dll'] if use_opencl() else ['dll']
     tests = make_suite(loaders, ['all'])
-    for test_i in tests:
-        # In order for nosetest to see the correct test name, need to set
-        # the description attribute of the returned function.  Since we
-        # can't do this for the returned instance, wrap it in a lambda and
-        # set the description on the lambda.  Otherwise we could just do:
-        #    yield test_i.run_all
-        L = lambda: test_i.run_all()
-        L.description = test_i.test_name
-        yield L
+    def build_test(test):
+        # In order for nosetest to show the test name, wrap the test.run_all
+        # instance in function that takes the test name as a parameter which
+        # will be displayed when the test is run.  Do this as a function so
+        # that it properly captures the context for tests that captured and
+        # run later.  If done directly in the for loop, then the looping
+        # variable test will be shared amongst all the tests, and we will be
+        # repeatedly testing vesicle.
+
+        # Note: in sasview sas.sasgui.perspectives.fitting.gpu_options
+        # requires that the test.description field be set.
+        wrap = lambda: test.run_all()
+        wrap.description = test.test_name
+        return wrap
+        # The following would work with nosetests and pytest:
+        #     return lambda name: test.run_all(), test.test_name
+
+    for test in tests:
+        yield build_test(test)
 
 
Index: sasmodels/modelinfo.py
===================================================================
--- sasmodels/modelinfo.py	(revision 5ab99b7733c7a08b8ef5bf95cdbd369b3b0a046a)
+++ sasmodels/modelinfo.py	(revision 95498a34dd5bf649c8e35fa888ca2bbdab9f3d77)
@@ -74,4 +74,5 @@
         processed.append(parse_parameter(*p))
     partable = ParameterTable(processed)
+    partable.check_angles()
     return partable
 
@@ -426,5 +427,4 @@
         # type: (List[Parameter]) -> None
         self.kernel_parameters = parameters
-        self._check_angles()
         self._set_vector_lengths()
 
@@ -476,5 +476,8 @@
         self.pd_2d = set(p.name for p in self.call_parameters if p.polydisperse)
 
-    def _check_angles(self):
+    def check_angles(self):
+        """
+        Check that orientation angles are theta, phi and possibly psi.
+        """
         theta = phi = psi = -1
         for k, p in enumerate(self.kernel_parameters):
@@ -499,7 +502,7 @@
             if psi >= 0 and psi != phi+1:
                 raise TypeError("psi must follow phi")
-            #if (psi >= 0 and psi != last_par) or (psi < 0 and phi != last_par):
-            #    raise TypeError("orientation parameters must appear at the "
-            #                    "end of the parameter table")
+            if (psi >= 0 and psi != last_par) or (psi < 0 and phi != last_par):
+                raise TypeError("orientation parameters must appear at the "
+                                "end of the parameter table")
         elif theta >= 0 or phi >= 0 or psi >= 0:
             raise TypeError("oriented shapes must have both theta and phi and maybe psi")
Index: sasmodels/models/core_shell_bicelle_elliptical.py
===================================================================
--- sasmodels/models/core_shell_bicelle_elliptical.py	(revision 2d81cfe3be42579d563407fb9275b7b516fee234)
+++ sasmodels/models/core_shell_bicelle_elliptical.py	(revision fc3ae1b048f9f1a5decf2a1e56be7ff62491e36b)
@@ -9,5 +9,4 @@
 scattering length densities. The form factor is normalized by the total
 particle volume.
-
 
 .. figure:: img/core_shell_bicelle_geometry.png
@@ -91,10 +90,9 @@
 for further information.
 
-
 .. figure:: img/elliptical_cylinder_angle_definition.png
 
     Definition of the angles for the oriented core_shell_bicelle_elliptical particles.
 
-
+Model verified using Monte Carlo simulation for 1D and 2D scattering.
 
 References
@@ -108,5 +106,5 @@
 * **Author:** Richard Heenan **Date:** December 14, 2016
 * **Last Modified by:**  Richard Heenan **Date:** December 14, 2016
-* **Last Reviewed by:**  Richard Heenan BEWARE 2d data yet to be checked **Date:** December 14, 2016
+* **Last Reviewed by:**  Paul Kienzle **Date:** Feb 28, 2018
 """
 
@@ -130,6 +128,6 @@
 #             ["name", "units", default, [lower, upper], "type", "description"],
 parameters = [
-    ["radius",         "Ang",       30, [0, inf],    "volume",      "Cylinder core radius"],
-    ["x_core",        "None",       3,  [0, inf],    "volume",      "axial ratio of core, X = r_polar/r_equatorial"],
+    ["radius",         "Ang",       30, [0, inf],    "volume",      "Cylinder core radius r_minor"],
+    ["x_core",        "None",       3,  [0, inf],    "volume",      "Axial ratio of core, X = r_major/r_minor"],
     ["thick_rim",  "Ang",            8, [0, inf],    "volume",      "Rim shell thickness"],
     ["thick_face", "Ang",           14, [0, inf],    "volume",      "Cylinder face thickness"],
@@ -139,7 +137,7 @@
     ["sld_rim",        "1e-6/Ang^2", 1, [-inf, inf], "sld",         "Cylinder rim scattering length density"],
     ["sld_solvent",    "1e-6/Ang^2", 6, [-inf, inf], "sld",         "Solvent scattering length density"],
-    ["theta",       "degrees",    90.0, [-360, 360], "orientation", "cylinder axis to beam angle"],
-    ["phi",         "degrees",    0,    [-360, 360], "orientation", "rotation about beam"],
-    ["psi",         "degrees",    0,    [-360, 360], "orientation", "rotation about cylinder axis"]
+    ["theta",       "degrees",    90.0, [-360, 360], "orientation", "Cylinder axis to beam angle"],
+    ["phi",         "degrees",    0,    [-360, 360], "orientation", "Rotation about beam"],
+    ["psi",         "degrees",    0,    [-360, 360], "orientation", "Rotation about cylinder axis"]
     ]
 
Index: sasmodels/models/core_shell_bicelle_elliptical_belt_rough.py
===================================================================
--- sasmodels/models/core_shell_bicelle_elliptical_belt_rough.py	(revision 108e70e22154db937a6d46f66be6dd70408cce6d)
+++ sasmodels/models/core_shell_bicelle_elliptical_belt_rough.py	(revision fc3ae1b048f9f1a5decf2a1e56be7ff62491e36b)
@@ -140,6 +140,6 @@
 #             ["name", "units", default, [lower, upper], "type", "description"],
 parameters = [
-    ["radius",         "Ang",       30, [0, inf],    "volume",      "Cylinder core radius"],
-    ["x_core",        "None",       3,  [0, inf],    "volume",      "axial ratio of core, X = r_polar/r_equatorial"],
+    ["radius",         "Ang",       30, [0, inf],    "volume",      "Cylinder core radius r_minor"],
+    ["x_core",        "None",       3,  [0, inf],    "volume",      "Axial ratio of core, X = r_major/r_minor"],
     ["thick_rim",  "Ang",            8, [0, inf],    "volume",      "Rim or belt shell thickness"],
     ["thick_face", "Ang",           14, [0, inf],    "volume",      "Cylinder face thickness"],
@@ -149,8 +149,8 @@
     ["sld_rim",        "1e-6/Ang^2", 1, [-inf, inf], "sld",         "Cylinder rim scattering length density"],
     ["sld_solvent",    "1e-6/Ang^2", 6, [-inf, inf], "sld",         "Solvent scattering length density"],
-    ["sigma",       "Ang",        0,    [0, inf],    "",            "interfacial roughness"],
-    ["theta",       "degrees",    90.0, [-360, 360], "orientation", "cylinder axis to beam angle"],
-    ["phi",         "degrees",    0,    [-360, 360], "orientation", "rotation about beam"],
-    ["psi",         "degrees",    0,    [-360, 360], "orientation", "rotation about cylinder axis"],
+    ["sigma",       "Ang",        0,    [0, inf],    "",            "Interfacial roughness"],
+    ["theta",       "degrees",    90.0, [-360, 360], "orientation", "Cylinder axis to beam angle"],
+    ["phi",         "degrees",    0,    [-360, 360], "orientation", "Rotation about beam"],
+    ["psi",         "degrees",    0,    [-360, 360], "orientation", "Rotation about cylinder axis"],
     ]
 
Index: sasmodels/models/core_shell_cylinder.c
===================================================================
--- sasmodels/models/core_shell_cylinder.c	(revision 108e70e22154db937a6d46f66be6dd70408cce6d)
+++ sasmodels/models/core_shell_cylinder.c	(revision d86f0fc0fff835c5ddf398e64c046518fa4536fc)
@@ -48,5 +48,6 @@
 
 
-double Iqac(double qab, double qc,
+static double
+Iqac(double qab, double qc,
     double core_sld,
     double shell_sld,
Index: sasmodels/models/hollow_rectangular_prism.c
===================================================================
--- sasmodels/models/hollow_rectangular_prism.c	(revision 108e70e22154db937a6d46f66be6dd70408cce6d)
+++ sasmodels/models/hollow_rectangular_prism.c	(revision d86f0fc0fff835c5ddf398e64c046518fa4536fc)
@@ -1,7 +1,4 @@
-double form_volume(double length_a, double b2a_ratio, double c2a_ratio, double thickness);
-double Iq(double q, double sld, double solvent_sld, double length_a,
-          double b2a_ratio, double c2a_ratio, double thickness);
-
-double form_volume(double length_a, double b2a_ratio, double c2a_ratio, double thickness)
+static double
+form_volume(double length_a, double b2a_ratio, double c2a_ratio, double thickness)
 {
     double length_b = length_a * b2a_ratio;
@@ -16,5 +13,6 @@
 }
 
-double Iq(double q,
+static double
+Iq(double q,
     double sld,
     double solvent_sld,
@@ -85,5 +83,6 @@
 }
 
-double Iqabc(double qa, double qb, double qc,
+static double
+Iqabc(double qa, double qb, double qc,
     double sld,
     double solvent_sld,
Index: sasmodels/models/hollow_rectangular_prism_thin_walls.c
===================================================================
--- sasmodels/models/hollow_rectangular_prism_thin_walls.c	(revision 74768cb764a04223ec9e992e316cb8c211adfe34)
+++ sasmodels/models/hollow_rectangular_prism_thin_walls.c	(revision d86f0fc0fff835c5ddf398e64c046518fa4536fc)
@@ -1,7 +1,4 @@
-double form_volume(double length_a, double b2a_ratio, double c2a_ratio);
-double Iq(double q, double sld, double solvent_sld, double length_a,
-          double b2a_ratio, double c2a_ratio);
-
-double form_volume(double length_a, double b2a_ratio, double c2a_ratio)
+static double
+form_volume(double length_a, double b2a_ratio, double c2a_ratio)
 {
     double length_b = length_a * b2a_ratio;
@@ -11,5 +8,6 @@
 }
 
-double Iq(double q,
+static double
+Iq(double q,
     double sld,
     double solvent_sld,
Index: sasmodels/models/polymer_excl_volume.py
===================================================================
--- sasmodels/models/polymer_excl_volume.py	(revision 2d81cfe3be42579d563407fb9275b7b516fee234)
+++ sasmodels/models/polymer_excl_volume.py	(revision aa900158f288ffb14cd35ce414d65dcc836750dd)
@@ -17,6 +17,17 @@
 $a$ is the statistical segment length of the polymer chain,
 and $n$ is the degree of polymerization.
-This integral was later put into an almost analytical form as follows
+
+This integral was put into an almost analytical form as follows
 (Hammouda, 1993)
+
+.. math::
+
+    P(Q)=\frac{1}{\nu U^{1/2\nu}}
+    \left\{
+        \gamma\left(\frac{1}{2\nu},U\right) -
+        \frac{1}{U^{1/2\nu}}\gamma\left(\frac{1}{\nu},U\right)
+    \right\}
+
+and later recast as (for example, Hore, 2013; Hammouda & Kim, 2017)
 
 .. math::
@@ -29,5 +40,5 @@
 .. math::
 
-    \gamma(x,U)=\int_0^{U}dt\ exp(-t)t^{x-1}
+    \gamma(x,U)=\int_0^{U}dt\ \exp(-t)t^{x-1}
 
 and the variable $U$ is given in terms of the scattering vector $Q$ as
@@ -37,4 +48,14 @@
     U=\frac{Q^2a^2n^{2\nu}}{6} = \frac{Q^2R_{g}^2(2\nu+1)(2\nu+2)}{6}
 
+The two analytic forms are equivalent. In the 1993 paper
+
+.. math::
+
+    \frac{1}{\nu U^{1/2\nu}}
+
+has been factored out.
+
+**SasView implements the 1993 expression**.
+
 The square of the radius-of-gyration is defined as
 
@@ -43,9 +64,10 @@
     R_{g}^2 = \frac{a^2n^{2\nu}}{(2\nu+1)(2\nu+2)}
 
-Note that this model applies only in the mass fractal range (ie, $5/3<=m<=3$ )
-and **does not apply** to surface fractals ( $3<m<=4$ ).
-It also does not reproduce the rigid rod limit (m=1) because it assumes chain
-flexibility from the outset. It may cover a portion of the semi-flexible chain
-range ( $1<m<5/3$ ).
+.. note::
+    This model applies only in the mass fractal range (ie, $5/3<=m<=3$ )
+    and **does not apply** to surface fractals ( $3<m<=4$ ).
+    It also does not reproduce the rigid rod limit (m=1) because it assumes chain
+    flexibility from the outset. It may cover a portion of the semi-flexible chain
+    range ( $1<m<5/3$ ).
 
 A low-Q expansion yields the Guinier form and a high-Q expansion yields the
@@ -73,5 +95,5 @@
 .. math::
 
-    P(Q) = \frac{2}{Q^4R_{g}^4} \left[exp(-Q^2R_{g}^2) - 1 + Q^2R_{g}^2 \right]
+    P(Q) = \frac{2}{Q^4R_{g}^4} \left[\exp(-Q^2R_{g}^2) - 1 + Q^2R_{g}^2 \right]
 
 For 2D data: The 2D scattering intensity is calculated in the same way as 1D,
@@ -89,5 +111,10 @@
 
 B Hammouda, *SANS from Homogeneous Polymer Mixtures - A Unified Overview,
-Advances in Polym. Sci.* 106(1993) 87-133
+Advances in Polym. Sci.* 106 (1993) 87-133
+
+M Hore et al, *Co-Nonsolvency of Poly(n-isopropylacrylamide) in Deuterated
+Water/Ethanol Mixtures* 46 (2013) 7894-7901
+
+B Hammouda & M-H Kim, *The empirical core-chain model* 247 (2017) 434-440
 """
 
@@ -124,4 +151,6 @@
     with errstate(divide='ignore', invalid='ignore'):
         upow = power(usub, -0.5*porod_exp)
+        # Note: scipy gammainc is "regularized", being gamma(s,x)/Gamma(s),
+        # so need to scale by Gamma(s) to recover gamma(s, x).
         result = (porod_exp*upow *
                   (gamma(0.5*porod_exp)*gammainc(0.5*porod_exp, usub) -
Index: sasmodels/models/rectangular_prism.c
===================================================================
--- sasmodels/models/rectangular_prism.c	(revision 108e70e22154db937a6d46f66be6dd70408cce6d)
+++ sasmodels/models/rectangular_prism.c	(revision d86f0fc0fff835c5ddf398e64c046518fa4536fc)
@@ -1,12 +1,10 @@
-double form_volume(double length_a, double b2a_ratio, double c2a_ratio);
-double Iq(double q, double sld, double solvent_sld, double length_a,
-          double b2a_ratio, double c2a_ratio);
-
-double form_volume(double length_a, double b2a_ratio, double c2a_ratio)
+static double
+form_volume(double length_a, double b2a_ratio, double c2a_ratio)
 {
     return length_a * (length_a*b2a_ratio) * (length_a*c2a_ratio);
 }
 
-double Iq(double q,
+static double
+Iq(double q,
     double sld,
     double solvent_sld,
@@ -71,5 +69,6 @@
 
 
-double Iqabc(double qa, double qb, double qc,
+static double
+Iqabc(double qa, double qb, double qc,
     double sld,
     double solvent_sld,
Index: sasmodels/multiscat.py
===================================================================
--- sasmodels/multiscat.py	(revision b3703f51bafe67afb8acefe986ad7466720f755b)
+++ sasmodels/multiscat.py	(revision b3703f51bafe67afb8acefe986ad7466720f755b)
@@ -0,0 +1,686 @@
+#!/usr/bin/env python
+r"""
+Multiple scattering calculator
+
+Calculate multiple scattering using 2D FFT convolution.
+
+Usage:
+
+    -p, --probability: the scattering probability
+    -q, --qmax: that max q that you care about
+    -w, --window: the extension window (q is calculated for qmax*window)
+    -n, --nq: the number of mesh points (dq = qmax*window/nq)
+    -r, --random: generate a random parameter set
+    -2, --2d: perform the calculation for an oriented pattern
+    model_name
+    model_par=value ...
+
+Assume the probability of scattering is $p$. After each scattering event,
+$1-p$ neutrons will leave the system and go to the detector, and the remaining
+$p$ will scatter again.
+
+Let the scattering probability for $n$ scattering event at $q$ be $f_n(q)$,
+where
+.. math:: f_1(q) = \frac{I_1(q)}{\int I_1(q) dq}
+for $I_1(q)$, the single scattering from the system. After two scattering
+events, the scattering probability will be the convolution of the first
+scattering and itself, or $f_2(q) = (f_1*f_1)(q)$.  After $n$ events it will be
+$f_n(q) = (f_1 * \cdots * f_1)(q)$.  The total scattering is calculated
+as the weighted sum of $f_k$, with weights following the Poisson distribution
+.. math:: P(k; \lambda) = \frac{\lambda^k e^{-\lambda}}{k!}
+for $\lambda$ determined by the total thickness divided by the mean
+free path between scattering, giving
+.. math::
+    I(q) = \sum_{k=0}^\infty P(k; \lambda) f_k(q)
+The $k=0$ term is ignored since it involves no scattering.
+We cut the series when cumulative probability is less than cutoff $C=99\%$,
+which is $\max n$ such that
+.. math::
+    \frac{\sum_{k=1}^n \frac{P(k; \lambda)}{1 - P(0; \lambda)} < C
+
+Using the convolution theorem, where
+$F = \mathcal{F}(f)$ is the Fourier transform,
+.. math:: f * g = \mathcal{F}^{-1}\{\mathcal{F}\{f\} \cdot \mathcal{F}\{g\}\}
+so
+.. math:: f * \ldots * f = \mathcal{F}^{-1}\{ F^n \}
+Since the Fourier transform is a linear operator, we can move the polynomial
+expression for the convolution into the transform, giving
+.. math::
+    I(q) = \mathcal{F}^{-1}\left\{ \sum_{k=1}^{n} P(k; \lambda) F^k \right\}
+In the dilute limit $L \rightarrow 0$ only the $k=1$ term is active,
+and so
+.. math::
+    P(1; \lambda) = \lambda e{-\lambda} = \int I_1(q) dq
+therefore we compute
+.. math::
+    I(q) = \int I_1(q) dq \mathcal{F}^{-1}\left\{
+        \sum_{l=1}^{n} \frac{P(k; \lambda)}{P(1; \lambda))} F^k \right\}
+
+For speed we may use the fast fourier transform with a power of two.
+The resulting $I(q)$ will be linearly spaced and likely heavily oversampled.
+The usual pinhole or slit resolution calculation can performed from these
+calculated values.
+
+By default single precision OpenCL is used for the calculation.  Set the
+environment variable *SAS_OPENCL=none* to use double precision numpy FFT
+instead.  The OpenCL versions is about 10x faster on an elderly Mac with
+Intel HD 4000 graphics.  The single precision numerical artifacts don't
+seem to seriously impact overall accuracy, though they look pretty bad.
+"""
+
+from __future__ import print_function, division
+
+import argparse
+import time
+
+import numpy as np
+from numpy import pi
+from scipy.special import gamma
+
+from sasmodels import core
+from sasmodels import compare
+from sasmodels.resolution import Resolution, bin_edges
+from sasmodels.direct_model import call_kernel
+import sasmodels.kernelcl
+
+# TODO: select fast and accurate fft library
+# clFFT: https://github.com/clMathLibraries/clFFT (AMD's OpenCL)
+# - gpyfft: https://github.com/geggo/gpyfft (wraps clFFT)
+# - arrayfire: https://github.com/arrayfire (wraps clFFT and much more; +cuda)
+# pyFFT: https://github.com/fjarri-attic/pyfft (based on Apple's OpenCL; +cuda)
+# - Reikna: https://github.com/fjarri/reikna (evolved from pyfft)
+# genFFT: https://software.intel.com/en-us/articles/genFFT (Intel's OpenCL)
+# VexCL: https://vexcl.readthedocs.io/en/latest/ (c++ library)
+# TODO: switch to fftw when opencl is not available
+
+try:
+    import pyfft.cl
+    import pyopencl.array as cl_array
+    HAVE_OPENCL = sasmodels.kernelcl.use_opencl()
+except ImportError:
+    HAVE_OPENCL = False
+PRECISION = np.dtype('f' if HAVE_OPENCL else 'd')  # 'f' or 'd'
+USE_FAST = True  # OpenCL faster, less accurate math
+
+class ICalculator:
+    """
+    Multiple scattering calculator
+    """
+    def fft(self, Iq):
+        """
+        Compute the forward FFT for an image, real -> complex.
+        """
+        raise NotImplementedError()
+
+    def ifft(self, Iq):
+        """
+        Compute the inverse FFT for an image, complex -> complex.
+        """
+        raise NotImplementedError()
+
+    def mulitple_scattering(self, Iq):
+        r"""
+        Compute multiple scattering for I(q) given scattering probability p.
+
+        Given a probability p of scattering with the thickness, the expected
+        number of scattering events, $\lambda$ is $-\log(1 - p)$, giving a
+        Poisson weighted sum of single, double, triple, etc. scattering patterns.
+        The number of patterns used is based on coverage (default 99%).
+        """
+        raise NotImplementedError()
+
+class NumpyCalculator(ICalculator):
+    """
+    Multiple scattering calculator using numpy fft.
+    """
+    def __init__(self, dims=None, dtype=PRECISION):
+        self.dtype = dtype
+        self.complex_dtype = np.dtype('F') if dtype == np.dtype('f') else np.dtype('D')
+
+    def fft(self, Iq):
+        #t0 = time.time()
+        Iq = np.asarray(Iq, self.dtype)
+        result = np.fft.fft2(Iq)
+        #print("fft time", time.time()-t0)
+        return result
+
+    def ifft(self, fourier_frame):
+        #t0 = time.time()
+        fourier_frame = np.asarray(fourier_frame, self.complex_dtype)
+        result = np.fft.ifft2(fourier_frame)
+        #print("ifft time", time.time()-t0)
+        return result
+
+    def multiple_scattering(self, Iq, p, coverage=0.99):
+        #t0 = time.time()
+        coeffs = scattering_coeffs(p, coverage)
+        poly = np.asarray(coeffs[::-1], dtype=self.dtype)
+        scale = np.sum(Iq)
+        frame = _forward_shift(Iq/scale, dtype=self.dtype)
+        fourier_frame = np.fft.fft2(frame)
+        convolved = fourier_frame * np.polyval(poly, fourier_frame)
+        frame = np.fft.ifft2(convolved)
+        result = scale * _inverse_shift(frame.real, dtype=self.dtype)
+        #print("numpy multiscat time", time.time()-t0)
+        return result
+
+# polyval1(c, x) computes (...((c0 x + c1) x + c2) x ... + cn) x
+# where c is an array of length *degree* and x is an array of
+# complex values (type double2) of length *n*. 2-D arrays can of
+# course be treated as 1-D arrays of length *nx* X *ny*.
+# When compiling with sasmodels.kernelcl.compile_model the double precision
+# types are converted to single precision as needed.  See the code in
+# sasmodels.generate._convert_type for details.
+POLYVAL1_KERNEL = """
+kernel void polyval1(
+    const int degree,
+    global const double *coeff,
+    const int n,
+    global double2 *array)
+{
+    int index = get_global_id(0);
+    if (index < n) {
+        const double2 x = array[index];
+        double2 total = coeff[0];
+        for (int k=1; k < degree; k++) {
+            total = fma(total, x, coeff[k]);
+        }
+        array[index] = total * x;
+    }
+}
+"""
+
+class OpenclCalculator(ICalculator):
+    """
+    Multiple scattering calculator using OpenCL via pyfft.
+    """
+    polyval1f = None
+    polyval1d = None
+    def __init__(self, dims, dtype=PRECISION):
+        env = sasmodels.kernelcl.environment()
+        context = env.get_context(dtype)
+        if dtype == np.dtype('f'):
+            if OpenclCalculator.polyval1f is None:
+                program = sasmodels.kernelcl.compile_model(
+                    context, POLYVAL1_KERNEL, dtype, fast=USE_FAST)
+                # Assume context is always the same for a given dtype
+                OpenclCalculator.polyval1f = program.polyval1
+            self.dtype = dtype
+            self.complex_dtype = np.dtype('F')
+            self.polyval1 = OpenclCalculator.polyval1f
+        else:
+            if OpenclCalculator.polyval1d is None:
+                program = sasmodels.kernelcl.compile_model(
+                    context, POLYVAL1_KERNEL, dtype, fast=False)
+                # Assume context is always the same for a given dtype
+                OpenclCalculator.polyval1d = program.polyval1
+            self.dtype = dtype
+            self.complex_type = np.dtype('D')
+            self.polyval1 = OpenclCalculator.polyval1d
+        self.queue = env.get_queue(dtype)
+        self.plan = pyfft.cl.Plan(dims, queue=self.queue)
+
+    def fft(self, Iq):
+        # forward transform
+        #t0 = time.time()
+        data = np.asarray(Iq, self.complex_dtype)
+        gpu_data = cl_array.to_device(self.queue, data)
+        self.plan.execute(gpu_data.data)
+        result = gpu_data.get()
+        #print("fft time", time.time()-t0)
+        return result
+
+    def ifft(self, fourier_frame):
+        # inverse transform
+        #t0 = time.time()
+        data = np.asarray(fourier_frame, self.complex_dtype)
+        gpu_data = cl_array.to_device(self.queue, data)
+        self.plan.execute(gpu_data.data, inverse=True)
+        result = gpu_data.get()
+        #print("ifft time", time.time()-t0)
+        return result
+
+    def multiple_scattering(self, Iq, p, coverage=0.99):
+        #t0 = time.time()
+        coeffs = scattering_coeffs(p, coverage)
+        scale = np.sum(Iq)
+        poly = np.asarray(coeffs[::-1], self.dtype)
+        frame = _forward_shift(Iq/scale, dtype=self.complex_dtype)
+        gpu_data = cl_array.to_device(self.queue, frame)
+        gpu_poly = cl_array.to_device(self.queue, poly)
+        self.plan.execute(gpu_data.data)
+        degree, data_size= poly.shape[0], frame.shape[0]*frame.shape[1]
+        self.polyval1(
+            self.queue, [data_size], None,
+            np.int32(degree), gpu_poly.data, np.int32(data_size), gpu_data.data)
+        self.plan.execute(gpu_data.data, inverse=True)
+        frame = gpu_data.get()
+        #result = scale * _inverse_shift(frame.real, dtype=self.dtype)
+        result = scale * _inverse_shift(frame.real, dtype=self.dtype)
+        #print("OpenCL multiscat time", time.time()-t0)
+        return result
+
+Calculator = OpenclCalculator if HAVE_OPENCL else NumpyCalculator
+
+def scattering_powers(Iq, n, dtype='f', transform=None):
+    r"""
+    Calculate the scattering powers up to n.
+
+    This includes 1 even though it should just be Iq itself
+
+    The frames are unweighted; to weight scale by $\lambda^k e^{-\lambda}/k!$.
+    """
+    if transform is None:
+        n_x, n_y = Iq.shape
+        transform = Calculator(dims=(n_x*2, n_y*2), dtype=dtype)
+    scale = np.sum(Iq)
+    frame = _forward_shift(Iq/scale, dtype=dtype)
+    F = transform.fft(frame)
+    powers = [scale * _inverse_shift(transform.ifft(F**(k+1)).real, dtype=dtype)
+              for k in range(n)]
+    return powers
+
+def scattering_coeffs(p, coverage=0.99):
+    r"""
+    Return the coefficients of the scattering powers for transmission
+    probability *p*.  This is just the corresponding values for the
+    Poisson distribution for $\lambda = -\ln(1-p)$ such that
+    $\sum_{k = 0 \ldots n} P(k; \lambda)$ is larger than *coverage*.
+    """
+    L = -np.log(1-p)
+    num_scatter = truncated_poisson_invcdf(coverage, L)
+    coeffs = [L**k/gamma(k+2) for k in range(num_scatter)]
+    return coeffs
+
+def truncated_poisson_invcdf(coverage, L):
+    r"""
+    Return smallest k such that cdf(k; L) > coverage from the truncated Poisson
+    probability excluding k=0
+    """
+    # pmf(k; L) = L**k * exp(-L) / (k! * (1-exp(-L))
+    cdf = 0
+    pmf = -np.exp(-L) / np.expm1(-L)
+    k = 0
+    while cdf < coverage:
+        k += 1
+        pmf *= L/k
+        cdf += pmf
+    return k
+
+def _forward_shift(Iq, dtype=PRECISION):
+    # Prepare padded array and forward transform
+    nq = Iq.shape[0]
+    half_nq = nq//2
+    frame = np.zeros((2*nq, 2*nq), dtype=dtype)
+    frame[:half_nq, :half_nq] = Iq[half_nq:, half_nq:]
+    frame[-half_nq:, :half_nq] = Iq[:half_nq, half_nq:]
+    frame[:half_nq, -half_nq:] = Iq[half_nq:, :half_nq]
+    frame[-half_nq:, -half_nq:] = Iq[:half_nq, :half_nq]
+    return frame
+
+def _inverse_shift(frame, dtype=PRECISION):
+    # Invert the transform and recover the transformed data
+    nq = frame.shape[0]//2
+    half_nq = nq//2
+    Iq = np.empty((nq, nq), dtype=dtype)
+    Iq[half_nq:, half_nq:] = frame[:half_nq, :half_nq]
+    Iq[:half_nq, half_nq:] = frame[-half_nq:, :half_nq]
+    Iq[half_nq:, :half_nq] = frame[:half_nq, -half_nq:]
+    Iq[:half_nq, :half_nq] = frame[-half_nq:, -half_nq:]
+    return Iq
+
+
+class MultipleScattering(Resolution):
+    r"""
+    Compute multiple scattering using Fourier convolution.
+
+    The fourier steps are determined by *qmax*, the maximum $q$ value
+    desired, *nq* the number of $q$ steps and *window*, the amount
+    of padding around the circular convolution.  The $q$ spacing
+    will be $\Delta q = 2 q_\mathrm{max} w / n_q$.  If *nq* is not
+    given it will use $n_q = 2^k$ such that $\Delta q < q_\mathrm{min}$.
+
+    *probability* is related to the expected number of scattering
+    events in the sample $\lambda$ as $p = 1 = e^{-\lambda}$.  As a
+    hack to allow probability to be a fitted parameter, the "value"
+    can be a function that takes no parameters and returns the current
+    value of the probability.  *coverage* determines how many scattering
+    steps to consider.  The default is 0.99, which sets $n$ such that
+    $1 \ldots n$ covers 99% of the Poisson probability mass function.
+
+    *is2d* is True then 2D scattering is used, otherwise it accepts
+    and returns 1D scattering.
+
+    *resolution* is the resolution function to apply after multiple
+    scattering.  If present, then the resolution $q$ vectors will provide
+    default values for *qmin*, *qmax* and *nq*.
+    """
+    def __init__(self, qmin=None, qmax=None, nq=None, window=2,
+                 probability=None, coverage=0.99,
+                 is2d=False, resolution=None,
+                 dtype=PRECISION):
+        # Infer qmin, qmax from instrument resolution calculator, if present
+        if resolution is not None:
+            is2d = hasattr(resolution, 'qx_data')
+            if is2d:
+                # 2D data
+                if qmax is None:
+                    qx_calc, qy_calc = resolution.q_calc
+                    qmax = np.sqrt(np.max(qx_calc**2 + qy_calc**2))
+                if qmin is None and nq is None:
+                    qx, qy = resolution.data.x_bins, resolution.data.y_bins
+                    if qx and qy:
+                        dx = (np.max(qx) - np.min(qx)) / len(qx)
+                        dy = (np.max(qy) - np.min(qy)) / len(qy)
+                    else:
+                        qx, qy = resolution.data.qx_data, resolution.data.qy_data
+                        steps = np.sqrt(len(qx))
+                        dx = (np.max(qx) - np.min(qx)) / steps
+                        dy = (np.max(qy) - np.min(qy)) / steps
+                    qmin = min(dx, dy)
+            else:
+                # 1D data
+                if qmax is None:
+                    qmax = np.max(resolution.q_calc)
+                if qmin is None and nq is None:
+                    qmin = np.min(np.abs(resolution.q_calc))
+
+        # estimate nq from qmin, qmax if not given explicitly
+        q_range = qmax * window
+        if nq is None:
+            nq = 2**np.ceil(np.log2(q_range/qmin))
+        nq = int(nq)
+        # Compute available qmin based on nq
+        qmin = 2*q_range / nq
+        #print(nq)
+
+        # remember input parameters
+        self.qmax = qmax
+        self.qmin = qmin
+        self.nq = nq
+        self.probability = probability
+        self.coverage = coverage
+        self.is2d = is2d
+        self.window = window
+        self.resolution = resolution
+
+        # Determine the q values to calculate
+        q = np.linspace(-q_range, q_range, nq)
+        qx, qy = np.meshgrid(q, q)
+        if is2d:
+            q_calc = (qx.flatten(), qy.flatten())
+        else:
+            # For 1-D patterns, compute q from the center to the corners and
+            # interpolate from there into the individual pixels.  Given that
+            # nq represents the points in [-qmax*windows, qmax*window],
+            # then using 2*sqrt(2)*nq/2 will oversample the points in q by
+            # a factor of two relative to the pixels.
+            q_range_to_corner = np.sqrt(2.) * q_range
+            nq_to_corner = 10*int(np.ceil(np.sqrt(2.) * nq))
+            q_to_corner = np.linspace(0, q_range_to_corner, nq_to_corner+1)[1:]
+            q_calc = (q_to_corner,)
+            # Remember the q radii of the calculated points
+            self._radius = np.sqrt(qx**2 + qy**2)
+            #self._q = q_to_corner
+        self._q_steps = q
+        self.q_calc = q_calc
+
+        # TODO: use cleaner data representation than that from sasview
+        # Resolution function forwards underlying q data (for plotting, etc?)
+        if is2d:
+            if resolution is not None:
+                # forward resolution function info to multiscattering
+                self.qx_data = resolution.qx_data
+                self.qy_data = resolution.qy_data
+            else:
+                # no underlying resolution function, but make it look like there is
+                self.qx_data, self.qy_data = q_calc
+        else:
+            # 1-D radial profile is determined by the q values we need to
+            # compute, either for the calculated q values for the resolution
+            # function (if any) or for the raw q values desired
+            self._q = np.linspace(qmin, qmax, nq//(2*window))
+            self._edges = bin_edges(self._q)
+            self._norm, _ = np.histogram(self._radius, bins=self._edges)
+            if resolution is not None:
+                self.q = resolution.q
+            else:
+                # no underlying resolution function, but make it look like there is
+                self.q = self._q
+
+        # Prepare the multiple scattering calculator (either numpy or OpenCL)
+        self.transform = Calculator((2*nq, 2*nq), dtype=dtype)
+
+        # Iq and Iqxy will be set during apply
+        self.Iq = None # type: np.ndarray
+        self.Iqxy = None # type: np.ndarray
+
+    def apply(self, theory):
+        if self.is2d:
+            Iq_calc = theory
+        else:
+            Iq_calc = np.interp(self._radius, self.q_calc[0], theory)
+        Iq_calc = Iq_calc.reshape(self.nq, self.nq)
+
+        probability = self.probability() if callable(self.probability) else self.probability
+        coverage = self.coverage
+        #t0 = time.time()
+        Iqxy = self.transform.multiple_scattering(Iq_calc, probability, coverage)
+        #print("multiple scattering calc time", time.time()-t0)
+
+        # remember the intermediate result in case we want to see it later
+        self.Iqxy = Iqxy
+
+        if self.is2d:
+            if self.resolution is not None:
+                Iqxy = self.resolution.apply(Iqxy)
+            return Iqxy
+        else:
+            # remember the intermediate result in case we want to see it later
+            Iq = self.radial_profile(Iqxy)
+            self.Iq = Iq
+            if self.resolution is not None:
+                q = self._q
+                Iq_res = np.interp(np.abs(self.resolution.q_calc), q, self.Iq)
+                _ = """
+                k = 6
+                print("q theory", q[:k])
+                print("Iq theory", theory[:k])
+                print("interp NaN?", np.any(np.isnan(Iq_calc)))
+                print("convolved NaN?", np.any(np.isnan(Iqxy)))
+                print("Iq intgrated", self.Iq[:k])
+                print("radius", self._radius[self.nq/2,self.nq/2:self.nq/2+k])
+                print("q edges", self._edges[:k+1])
+                print("Iq norm", self._norm[:k])
+                print("res q", self.resolution.q_calc[:k])
+                print("Iq res", Iq_res[:k])
+                #print(Iq)
+                #print(Iq_res)
+                """
+                Iq = self.resolution.apply(Iq_res)
+            return Iq
+
+    def radial_profile(self, Iqxy):
+        """
+        Compute that radial profile for the given Iqxy grid.  The grid should
+        be defined as for
+        """
+        # circular average, no anti-aliasing
+        Iq = np.histogram(self._radius, bins=self._edges, weights=Iqxy)[0]/self._norm
+        return Iq
+
+
+def annular_average(qxy, Iqxy, qbins):
+    """
+    Compute annular average of points in *Iqxy* at *qbins*.  The $q_x$, $q_y$
+    coordinates for *Iqxy* are given in *qxy*.
+    """
+    qxy, Iqxy = qxy.flatten(), Iqxy.flatten()
+    index = np.argsort(qxy)
+    qxy, Iqxy = qxy[index], Iqxy[index]
+    print(qxy.shape, Iqxy.shape, index.shape, qbins.shape)
+    #values = rebin(np.vstack((0., qxy)), Iqxy, qbins)
+    integral = np.cumsum(Iqxy)
+    Io = np.diff(np.interp(qbins, qxy, integral, left=0.))
+    # normalize by area of annulus
+    # TODO: doesn't properly account for box.
+    # Need to chop off chords that are greater than the box edges
+    # (left, right, top and bottom), then add back in the corners
+    # chopped off by both. https://en.wikipedia.org/wiki/Circular_segment
+    norms = np.diff(pi*qbins**2)
+    return Io/norms
+
+def rebin(x, I, xo):
+    """
+    Rebin from edges *x*, bins I into edges *xo*.
+
+    *x* and *xo* should be monotonically increasing.
+
+    If *x* has duplicate values, then all corresponding values at I(x) will
+    be effectively summed into the same bin.  If *xo* has duplicate values,
+    the first bin will contain the entire contents and subsequent bins will
+    contain zeros.
+    """
+    integral = np.cumsum(I)
+    Io = np.diff(np.interp(xo, x[1:], integral, left=0.))
+    return Io
+
+
+def parse_pars(model, opts):
+    # type: (ModelInfo, argparse.Namespace) -> Dict[str, float]
+    """
+    Parse par=val arguments from the command line.
+    """
+
+    seed = np.random.randint(1000000) if opts.random and opts.seed < 0 else opts.seed
+    compare_opts = {
+        'info': (model.info, model.info),
+        'use_demo': False,
+        'seed': seed,
+        'mono': True,
+        'magnetic': False,
+        'values': opts.pars,
+        #'show_pars': True,
+        'show_pars': False,
+        'is2d': opts.is2d,
+    }
+    # Note: sascomp allows comparison on a pair of models, so ignore the second.
+    pars, _ = compare.parse_pars(compare_opts)
+    return pars
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Compute multiple scattering",
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+        )
+    parser.add_argument('-p', '--probability', type=float, default=0.1,
+                        help="scattering probability")
+    parser.add_argument('-n', '--nq', type=int, default=1024,
+                        help='number of mesh points')
+    parser.add_argument('-q', '--qmax', type=float, default=0.5,
+                        help='max q')
+    parser.add_argument('-w', '--window', type=float, default=2.0,
+                        help='q calc = q max * window')
+    parser.add_argument('-2', '--2d', dest='is2d', action='store_true',
+                        help='oriented sample')
+    parser.add_argument('-s', '--seed', default=-1,
+                        help='random pars with given seed')
+    parser.add_argument('-r', '--random', action='store_true',
+                        help='random pars with random seed')
+    parser.add_argument('-o', '--outfile', type=str, default="",
+                        help='random pars with random seed')
+    parser.add_argument('model', type=str,
+                        help='sas model name such as cylinder')
+    parser.add_argument('pars', type=str, nargs='*',
+                        help='model parameters such as radius=30')
+    opts = parser.parse_args()
+    assert opts.nq%2 == 0, "require even # points"
+
+    model = core.load_model(opts.model)
+    pars = parse_pars(model, opts)
+    res = MultipleScattering(qmax=opts.qmax, nq=opts.nq, window=opts.window,
+                             probability=opts.probability, is2d=opts.is2d)
+    kernel = model.make_kernel(res.q_calc)
+    #print(pars)
+    bg = pars.get('background', 0.0)
+    pars['background'] = 0.0
+    theory = call_kernel(kernel, pars)
+    Iq = res.apply(theory) + bg
+    plot_and_save_powers(res, theory, Iq, outfile=opts.outfile, background=bg)
+
+def plot_and_save_powers(res, theory, result, plot=True, outfile="", background=0.):
+    import pylab
+    probability, coverage = res.probability, res.coverage
+    weights = scattering_coeffs(probability, coverage)
+
+    # cribbed from MultipleScattering.apply
+    if res.is2d:
+        Iq_calc = theory
+    else:
+        Iq_calc = np.interp(res._radius, res.q_calc[0], theory)
+    Iq_calc = Iq_calc.reshape(res.nq, res.nq)
+
+    # Compute the scattering powers for 1, 2, ... n scattering events
+    powers = scattering_powers(Iq_calc, len(weights))
+
+    #plotxy(Iqxy); import pylab; pylab.figure()
+    if res.is2d:
+        if outfile:
+            data = np.vstack([Ipower.flatten() for Ipower in powers]).T
+            np.savetxt(outfile + "_powers.txt", data)
+            data = np.vstack(Iq_calc).T
+            np.savetxt(outfile + ".txt", data)
+        if plot:
+            plotxy((res._q_steps, res._q_steps), Iq_calc)
+            pylab.title("single scattering F")
+            for k, v in enumerate(powers[1:]):
+                pylab.figure()
+                plotxy((res._q_steps, res._q_steps), v+background)
+                pylab.title("multiple scattering F^%d" % (k+2))
+            pylab.figure()
+            plotxy((res._q_steps, res._q_steps), res.Iqxy+background)
+            pylab.title("total scattering for p=%g" % probability)
+            if res.resolution is not None:
+                pylab.figure()
+                plotxy((res._q_steps, res._q_steps), result)
+                pylab.title("total scattering with resolution")
+    else:
+        q = res._q
+        Iq_powers = [res.radial_profile(Iqxy) for Iqxy in powers]
+        if outfile:
+            data = np.vstack([q, theory, res.Iq]).T
+            np.savetxt(outfile + ".txt", data)
+            # circular average, no anti-aliasing for individual powers
+            data = np.vstack([q] + Iq_powers).T
+            np.savetxt(outfile + "_powers.txt", data)
+        if plot:
+            # Plot 2D pattern for total scattering
+            plotxy((res._q_steps, res._q_steps), res.Iqxy+background)
+            pylab.title("total scattering for p=%g" % probability)
+            pylab.figure()
+
+            # Plot 1D pattern for partial scattering
+            pylab.loglog(q, res.Iq+background, label="total for p=%g"%probability)
+            if res.resolution is not None:
+                pylab.loglog(q, result, label="total with dQ")
+            #new_annulus = annular_average(res._radius, res.Iqxy, res._edges)
+            #pylab.loglog(q, new_annulus+background, label="new total for p=%g"%probability)
+            for n, (w, Ipower) in enumerate(zip(weights, Iq_powers)):
+                pylab.loglog(q, w*Ipower+background, label="scattering^%d"%(n+1))
+            pylab.legend()
+            pylab.title('total scattering for p=%g' % probability)
+    pylab.show()
+
+def plotxy(q, Iq):
+    import pylab
+    # q is a tuple of (q,) or (qx, qy)
+    if len(q) == 1:
+        pylab.loglog(q[0], Iq)
+    else:
+        data = Iq.copy()
+        data[Iq <= 0] = np.min(Iq[Iq > 0])/2
+        pylab.imshow(np.log10(data))
+
+if __name__ == "__main__":
+    main()
Index: sasmodels/rst2html.py
===================================================================
--- sasmodels/rst2html.py	(revision 2d81cfe3be42579d563407fb9275b7b516fee234)
+++ sasmodels/rst2html.py	(revision 1fbadb20289a15dcd2b077eb8ef47e0dbda5eef1)
@@ -56,5 +56,7 @@
     # others don't work properly with math_output!
     if math_output == "mathjax":
-        settings = {"math_output": math_output}
+        # TODO: this is copied from docs/conf.py; there should be only one
+        mathjax_path = "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/MathJax.js?config=TeX-MML-AM_CHTML"
+        settings = {"math_output": math_output + " " + mathjax_path}
     else:
         settings = {"math-output": math_output}
Index: sasmodels/sasview_model.py
===================================================================
--- sasmodels/sasview_model.py	(revision 2d81cfe3be42579d563407fb9275b7b516fee234)
+++ sasmodels/sasview_model.py	(revision d533590d9857d7aef962616cfdaa6ecfc2e6751d)
@@ -593,8 +593,5 @@
             # Check whether we have a list of ndarrays [qx,qy]
             qx, qy = qdist
-            if not self._model_info.parameters.has_2d:
-                return self.calculate_Iq(np.sqrt(qx ** 2 + qy ** 2))
-            else:
-                return self.calculate_Iq(qx, qy)
+            return self.calculate_Iq(qx, qy)
 
         elif isinstance(qdist, np.ndarray):
@@ -665,5 +662,4 @@
 
     def _calculate_Iq(self, qx, qy=None):
-        #core.HAVE_OPENCL = False
         if self._model is None:
             self._model = core.build_model(self._model_info)
@@ -678,5 +674,6 @@
         call_details, values, is_magnetic = make_kernel_args(calculator, pairs)
         #call_details.show()
-        #print("pairs", pairs)
+        #print("================ parameters ==================")
+        #for p, v in zip(parameters.call_parameters, pairs): print(p.name, v[0])
         #for k, p in enumerate(self._model_info.parameters.call_parameters):
         #    print(k, p.name, *pairs[k])
@@ -689,5 +686,5 @@
         self._intermediate_results = getattr(calculator, 'results', None)
         calculator.release()
-        self._model.release()
+        #self._model.release()
         return result
 
@@ -722,5 +719,5 @@
 
     def set_dispersion(self, parameter, dispersion):
-        # type: (str, weights.Dispersion) -> Dict[str, Any]
+        # type: (str, weights.Dispersion) -> None
         """
         Set the dispersion object for a model parameter
@@ -745,5 +742,6 @@
             self.dispersion[parameter] = dispersion.get_pars()
         else:
-            raise ValueError("%r is not a dispersity or orientation parameter")
+            raise ValueError("%r is not a dispersity or orientation parameter"
+                             % parameter)
 
     def _dispersion_mesh(self):
@@ -859,5 +857,5 @@
     # type: () -> None
     """
-    Load and run cylinder model from sas.models.CylinderModel
+    Load and run cylinder model as sas-models-CylinderModel
     """
     if not SUPPORT_OLD_STYLE_PLUGINS:
@@ -872,6 +870,20 @@
     CylinderModel().evalDistribution([0.1, 0.1])
 
+def magnetic_demo():
+    Model = _make_standard_model('sphere')
+    model = Model()
+    model.setParam('M0:sld', 8)
+    q = np.linspace(-0.35, 0.35, 500)
+    qx, qy = np.meshgrid(q, q)
+    result = model.calculate_Iq(qx.flatten(), qy.flatten())
+    result = result.reshape(qx.shape)
+
+    import pylab
+    pylab.imshow(np.log(result + 0.001))
+    pylab.show()
+
 if __name__ == "__main__":
     print("cylinder(0.1,0.1)=%g"%test_cylinder())
+    #magnetic_demo()
     #test_product()
     #test_structure_factor()
Index: sasmodels/weights.py
===================================================================
--- sasmodels/weights.py	(revision 2d81cfe3be42579d563407fb9275b7b516fee234)
+++ sasmodels/weights.py	(revision 3d5824792e80506c5c40f05fb2ddc2234a8acedf)
@@ -97,4 +97,18 @@
         return x, px
 
+class UniformDispersion(Dispersion):
+    r"""
+    Uniform dispersion, with width $\sigma$.
+
+    .. math::
+
+        w = 1
+    """
+    type = "uniform"
+    default = dict(npts=35, width=0, nsigmas=None)
+    def _weights(self, center, sigma, lb, ub):
+        x = np.linspace(center-sigma, center+sigma, self.npts)
+        x = x[(x >= lb) & (x <= ub)]
+        return x, np.ones_like(x)
 
 class RectangleDispersion(Dispersion):
@@ -107,10 +121,9 @@
     """
     type = "rectangle"
-    default = dict(npts=35, width=0, nsigmas=1.70325)
-    def _weights(self, center, sigma, lb, ub):
-        x = self._linspace(center, sigma, lb, ub)
-        x = x[np.fabs(x-center) <= np.fabs(sigma)*sqrt(3.0)]
-        return x, np.ones_like(x)
-
+    default = dict(npts=35, width=0, nsigmas=1.73205)
+    def _weights(self, center, sigma, lb, ub):
+         x = self._linspace(center, sigma, lb, ub)
+         x = x[np.fabs(x-center) <= np.fabs(sigma)*sqrt(3.0)]
+         return x, np.ones_like(x)
 
 class LogNormalDispersion(Dispersion):
@@ -190,4 +203,18 @@
         return x, px
 
+class BoltzmannDispersion(Dispersion):
+    r"""
+    Boltzmann dispersion, with $\sigma=k T/E$.
+
+    .. math::
+
+        w = \exp\left( -|x - c|/\sigma\right)
+    """
+    type = "boltzmann"
+    default = dict(npts=35, width=0, nsigmas=3)
+    def _weights(self, center, sigma, lb, ub):
+        x = self._linspace(center, sigma, lb, ub)
+        px = np.exp(-np.abs(x-center) / np.abs(sigma))
+        return x, px
 
 # dispersion name -> disperser lookup table.
@@ -196,8 +223,10 @@
 MODELS = OrderedDict((d.type, d) for d in (
     RectangleDispersion,
+    UniformDispersion,
     ArrayDispersion,
     LogNormalDispersion,
     GaussianDispersion,
     SchulzDispersion,
+    BoltzmannDispersion
 ))
 
Index: setup.py
===================================================================
--- setup.py	(revision 32e3c9bc7952e078b48a3a2ce4bd06b565d7326a)
+++ setup.py	(revision 1f991d6e4007acafaaf7edbc17c549a8b777d374)
@@ -1,6 +1,19 @@
-try:
-    from setuptools import setup
-except ImportError:
-    from distutils.core import setup
+import sys
+from setuptools import setup
+from setuptools.command.test import test as TestCommand
+
+class PyTest(TestCommand):
+    user_options = [('pytest-args=', 'a', "Arguments to pass to pytest")]
+
+    def initialize_options(self):
+        TestCommand.initialize_options(self)
+        self.pytest_args = ''
+
+    def run_tests(self):
+        import shlex
+        #import here, cause outside the eggs aren't loaded
+        import pytest
+        errno = pytest.main(shlex.split(self.pytest_args))
+        sys.exit(errno)
 
 def find_version(package):
@@ -48,10 +61,13 @@
         'sasmodels': ['*.c', '*.cl'],
     },
-    install_requires = [
+    install_requires=[
     ],
-    extras_require = {
+    extras_require={
         'OpenCL': ["pyopencl"],
         'Bumps': ["bumps"],
         'TinyCC': ["tinycc"],
     },
+    build_requires=['setuptools'],
+    test_requires=['pytest'],
+    cmdclass = {'test': PyTest},
 )
