Index: sasmodels/compare.py
===================================================================
--- sasmodels/compare.py	(revision 6869cebe777d56ffcea7cfcffb97e63d951bbf84)
+++ sasmodels/compare.py	(revision 17bbadde9de73711781f748816b06e6956dc8cfa)
@@ -38,8 +38,8 @@
 from . import core
 from . import kerneldll
-from . import generate
+from . import product
 from .data import plot_theory, empty_data1D, empty_data2D
 from .direct_model import DirectModel
-from .convert import revert_model, constrain_new_to_old
+from .convert import revert_pars, constrain_new_to_old
 
 USAGE = """
@@ -264,5 +264,5 @@
     return pars
 
-def constrain_pars(model_definition, pars):
+def constrain_pars(model_info, pars):
     """
     Restrict parameters to valid values.
@@ -272,5 +272,10 @@
     cylinder radius in this case).
     """
-    name = model_definition.name
+    name = model_info['id']
+    # if it is a product model, then just look at the form factor since
+    # none of the structure factors need any constraints.
+    if '*' in name:
+        name = name.split('*')[0]
+
     if name == 'capped_cylinder' and pars['cap_radius'] < pars['radius']:
         pars['radius'], pars['cap_radius'] = pars['cap_radius'], pars['radius']
@@ -340,5 +345,5 @@
     return pars
 
-def eval_sasview(model_definition, data):
+def eval_sasview(model_info, data):
     """
     Return a model calculator using the SasView fitting engine.
@@ -349,15 +354,26 @@
     from sas.models.qsmearing import smear_selection
 
-    # convert model parameters from sasmodel form to sasview form
-    #print("old",sorted(pars.items()))
-    modelname, _ = revert_model(model_definition, {})
-    #print("new",sorted(_pars.items()))
-    sas = __import__('sas.models.'+modelname)
-    ModelClass = getattr(getattr(sas.models, modelname, None), modelname, None)
-    if ModelClass is None:
-        raise ValueError("could not find model %r in sas.models"%modelname)
-    model = ModelClass()
+    def get_model(name):
+        #print("new",sorted(_pars.items()))
+        sas = __import__('sas.models.' + name)
+        ModelClass = getattr(getattr(sas.models, name, None), name, None)
+        if ModelClass is None:
+            raise ValueError("could not find model %r in sas.models"%name)
+        return ModelClass()
+
+    # grab the sasview model, or create it if it is a product model
+    if model_info['composition']:
+        composition_type, parts = model_info['composition']
+        if composition_type == 'product':
+            from sas.models import MultiplicationModel
+            P, S = [get_model(p) for p in model_info['oldname']]
+            model = MultiplicationModel(P, S)
+        else:
+            raise ValueError("mixture models not handled yet")
+    else:
+        model = get_model(model_info['oldname'])
+
+    # build a smearer with which to call the model, if necessary
     smearer = smear_selection(data, model=model)
-
     if hasattr(data, 'qx_data'):
         q = np.sqrt(data.qx_data**2 + data.qy_data**2)
@@ -382,5 +398,5 @@
         """
         # paying for parameter conversion each time to keep life simple, if not fast
-        _, pars = revert_model(model_definition, pars)
+        pars = revert_pars(model_info, pars)
         for k, v in pars.items():
             parts = k.split('.')  # polydispersity components
@@ -405,20 +421,30 @@
     'longdouble': '128',
 }
-def eval_opencl(model_definition, data, dtype='single', cutoff=0.):
+def eval_opencl(model_info, data, dtype='single', cutoff=0.):
     """
     Return a model calculator using the OpenCL calculation engine.
     """
-    try:
-        model = core.load_model(model_definition, dtype=dtype, platform="ocl")
-    except Exception as exc:
-        print(exc)
-        print("... trying again with single precision")
-        dtype = 'single'
-        model = core.load_model(model_definition, dtype=dtype, platform="ocl")
+    def builder(model_info):
+        try:
+            return core.build_model(model_info, dtype=dtype, platform="ocl")
+        except Exception as exc:
+            print(exc)
+            print("... trying again with single precision")
+            dtype = 'single'
+            return core.build_model(model_info, dtype=dtype, platform="ocl")
+    if model_info['composition']:
+        composition_type, parts = model_info['composition']
+        if composition_type == 'product':
+            P, S = [builder(p) for p in parts]
+            model = product.ProductModel(P, S)
+        else:
+            raise ValueError("mixture models not handled yet")
+    else:
+        model = builder(model_info)
     calculator = DirectModel(data, model, cutoff=cutoff)
     calculator.engine = "OCL%s"%DTYPE_MAP[dtype]
     return calculator
 
-def eval_ctypes(model_definition, data, dtype='double', cutoff=0.):
+def eval_ctypes(model_info, data, dtype='double', cutoff=0.):
     """
     Return a model calculator using the DLL calculation engine.
@@ -426,5 +452,16 @@
     if dtype == 'quad':
         dtype = 'longdouble'
-    model = core.load_model(model_definition, dtype=dtype, platform="dll")
+    def builder(model_info):
+        return core.build_model(model_info, dtype=dtype, platform="dll")
+
+    if model_info['composition']:
+        composition_type, parts = model_info['composition']
+        if composition_type == 'product':
+            P, S = [builder(p) for p in parts]
+            model = product.ProductModel(P, S)
+        else:
+            raise ValueError("mixture models not handled yet")
+    else:
+        model = builder(model_info)
     calculator = DirectModel(data, model, cutoff=cutoff)
     calculator.engine = "OMP%s"%DTYPE_MAP[dtype]
@@ -470,5 +507,5 @@
     return data, index
 
-def make_engine(model_definition, data, dtype, cutoff):
+def make_engine(model_info, data, dtype, cutoff):
     """
     Generate the appropriate calculation engine for the given datatype.
@@ -478,11 +515,9 @@
     """
     if dtype == 'sasview':
-        return eval_sasview(model_definition, data)
+        return eval_sasview(model_info, data)
     elif dtype.endswith('!'):
-        return eval_ctypes(model_definition, data, dtype=dtype[:-1],
-                           cutoff=cutoff)
-    else:
-        return eval_opencl(model_definition, data, dtype=dtype,
-                           cutoff=cutoff)
+        return eval_ctypes(model_info, data, dtype=dtype[:-1], cutoff=cutoff)
+    else:
+        return eval_opencl(model_info, data, dtype=dtype, cutoff=cutoff)
 
 def compare(opts, limits=None):
@@ -642,14 +677,13 @@
 
 
-def get_demo_pars(model_definition):
+def get_demo_pars(model_info):
     """
     Extract demo parameters from the model definition.
     """
-    info = generate.make_info(model_definition)
     # Get the default values for the parameters
-    pars = dict((p[0], p[2]) for p in info['parameters'])
+    pars = dict((p[0], p[2]) for p in model_info['parameters'])
 
     # Fill in default values for the polydispersity parameters
-    for p in info['parameters']:
+    for p in model_info['parameters']:
         if p[4] in ('volume', 'orientation'):
             pars[p[0]+'_pd'] = 0.0
@@ -659,6 +693,7 @@
 
     # Plug in values given in demo
-    pars.update(info['demo'])
+    pars.update(model_info['demo'])
     return pars
+
 
 def parse_opts():
@@ -679,14 +714,22 @@
         print(columnize(MODELS, indent="  "))
         sys.exit(1)
-
-    name = args[0]
-    try:
-        model_definition = core.load_model_definition(name)
-    except ImportError, exc:
-        print(str(exc))
-        print("Use one of:\n    " + models)
-        sys.exit(1)
     if len(args) > 3:
         print("expected parameters: model N1 N2")
+
+    def load_model(name):
+        try:
+            model_info = core.load_model_info(name)
+        except ImportError, exc:
+            print(str(exc))
+            print("Use one of:\n    " + models)
+            sys.exit(1)
+        return model_info
+
+    name = args[0]
+    if '*' in name:
+        parts = [load_model(k) for k in name.split('*')]
+        model_info = product.make_product_info(*parts)
+    else:
+        model_info = load_model(name)
 
     invalid = [o[1:] for o in flags
@@ -770,5 +813,5 @@
     # Get demo parameters from model definition, or use default parameters
     # if model does not define demo parameters
-    pars = get_demo_pars(model_definition)
+    pars = get_demo_pars(model_info)
 
     # Fill in parameters given on the command line
@@ -791,6 +834,6 @@
         pars = suppress_pd(pars)
     pars.update(presets)  # set value after random to control value
-    constrain_pars(model_definition, pars)
-    constrain_new_to_old(model_definition, pars)
+    constrain_pars(model_info, pars)
+    constrain_new_to_old(model_info, pars)
     if opts['show_pars']:
         print(str(parlist(pars)))
@@ -799,9 +842,9 @@
     data, _ = make_data(opts)
     if n1:
-        base = make_engine(model_definition, data, engines[0], opts['cutoff'])
+        base = make_engine(model_info, data, engines[0], opts['cutoff'])
     else:
         base = None
     if n2:
-        comp = make_engine(model_definition, data, engines[1], opts['cutoff'])
+        comp = make_engine(model_info, data, engines[1], opts['cutoff'])
     else:
         comp = None
@@ -811,5 +854,5 @@
     opts.update({
         'name'      : name,
-        'def'       : model_definition,
+        'def'       : model_info,
         'n1'        : n1,
         'n2'        : n2,
@@ -854,11 +897,11 @@
         config_matplotlib()
         self.opts = opts
-        info = generate.make_info(opts['def'])
-        pars, pd_types = bumps_model.create_parameters(info, **opts['pars'])
+        model_info = opts['def']
+        pars, pd_types = bumps_model.create_parameters(model_info, **opts['pars'])
         if not opts['is2d']:
             active = [base + ext
-                      for base in info['partype']['pd-1d']
+                      for base in model_info['partype']['pd-1d']
                       for ext in ['', '_pd', '_pd_n', '_pd_nsigma']]
-            active.extend(info['partype']['fixed-1d'])
+            active.extend(model_info['partype']['fixed-1d'])
             for k in active:
                 v = pars[k]
Index: sasmodels/compare_many.py
===================================================================
--- sasmodels/compare_many.py	(revision 4f2478e77cdc4e495cf403a9aeb9c6544bcdf468)
+++ sasmodels/compare_many.py	(revision 17bbadde9de73711781f748816b06e6956dc8cfa)
@@ -101,6 +101,6 @@
 
     is_2d = hasattr(data, 'qx_data')
-    model_definition = core.load_model_definition(name)
-    pars = get_demo_pars(model_definition)
+    model_info = core.load_model_info(name)
+    pars = get_demo_pars(model_info)
     header = ('\n"Model","%s","Count","%d","Dimension","%s"'
               % (name, N, "2D" if is_2d else "1D"))
@@ -109,6 +109,5 @@
 
     if is_2d:
-        info = generate.make_info(model_definition)
-        partype = info['partype']
+        partype = model_info['partype']
         if not partype['orientation'] and not partype['magnetic']:
             print(',"1-D only"')
@@ -150,6 +149,6 @@
 
 
-    calc_base = make_engine(model_definition, data, base, cutoff)
-    calc_comp = make_engine(model_definition, data, comp, cutoff)
+    calc_base = make_engine(model_info, data, base, cutoff)
+    calc_comp = make_engine(model_info, data, comp, cutoff)
     expected = max(PRECISION[base], PRECISION[comp])
 
@@ -161,6 +160,6 @@
         seed = np.random.randint(1e6)
         pars_i = randomize_pars(pars, seed)
-        constrain_pars(model_definition, pars_i)
-        constrain_new_to_old(model_definition, pars_i)
+        constrain_pars(model_info['id'], pars_i)
+        constrain_new_to_old(model_info['id'], pars_i)
         if mono:
             pars_i = suppress_pd(pars_i)
Index: sasmodels/convert.py
===================================================================
--- sasmodels/convert.py	(revision 0d0aee160a94e4d66091b571a5117dc34ac0052a)
+++ sasmodels/convert.py	(revision 17bbadde9de73711781f748816b06e6956dc8cfa)
@@ -4,7 +4,13 @@
 import warnings
 
+STRUCTURE_FACTORS = [
+    'hardsphere',
+    'stickyhardsphere',
+    'squarewell',
+    'HayterMSAsq'
+]
 # List of models which SasView versions don't contain the explicit 'scale' argument.
 # When converting such a model, please update this list.
-MODELS_WITHOUT_SCALE = [
+MODELS_WITHOUT_SCALE = STRUCTURE_FACTORS + [
     'teubner_strey',
     'broad_peak',
@@ -15,11 +21,11 @@
     'be_polyelectrolyte',
     'correlation_length',
+    'fractal_core_shell'
     'binary_hard_sphere',
-    'fractal_core_shell'
 ]
 
 # List of models which SasView versions don't contain the explicit 'background' argument.
 # When converting such a model, please update this list.
-MODELS_WITHOUT_BACKGROUND = [
+MODELS_WITHOUT_BACKGROUND = STRUCTURE_FACTORS + [
     'guinier',
 ]
@@ -52,5 +58,7 @@
     new model definition end with sld.
     """
-    return dict((p, (v*1e6 if p.endswith('sld') else v*1e-15 if 'ndensity' in p else v))
+    return dict((p, (v*1e6 if p.endswith('sld')
+                     else v*1e-15 if 'ndensity' in p
+                     else v))
                 for p, v in pars.items())
 
@@ -70,5 +78,7 @@
     new model definition end with sld.
     """
-    return dict((p, (v*1e-6 if p.endswith('sld') else v*1e15 if 'ndensity' in p else v))
+    return dict((p, (v*1e-6 if p.endswith('sld')
+                     else v*1e15 if 'ndensity' in p
+                     else v))
                 for p, v in pars.items())
 
@@ -109,58 +119,63 @@
     return newpars
 
-def revert_model(model_definition, pars):
+def revert_pars(model_info, pars):
     """
     Convert model from new style parameter names to old style.
     """
-    mapping = model_definition.oldpars
-    oldname = model_definition.oldname
+    mapping = model_info['oldpars']
     oldpars = _revert_pars(_unscale_sld(pars), mapping)
 
     # Note: update compare.constrain_pars to match
-    name = model_definition.name
+    name = model_info['id']
     if name in MODELS_WITHOUT_SCALE:
         if oldpars.pop('scale', 1.0) != 1.0:
             warnings.warn("parameter scale not used in sasview %s"%name)
-    elif name in MODELS_WITHOUT_BACKGROUND:
+    if name in MODELS_WITHOUT_BACKGROUND:
         if oldpars.pop('background', 0.0) != 0.0:
             warnings.warn("parameter background not used in sasview %s"%name)
-    elif getattr(model_definition, 'category', None) == 'structure-factor':
-        if oldpars.pop('scale', 1.0) != 1.0:
-            warnings.warn("parameter scale not used in sasview %s"%name)
-        if oldpars.pop('background', 0.0) != 0.0:
-            warnings.warn("parameter background not used in sasview %s"%name)
-    elif name == 'pearl_necklace':
-        _remove_pd(oldpars, 'num_pearls', name)
-        _remove_pd(oldpars, 'thick_string', name)
-    elif name == 'core_shell_parallelepiped':
-        _remove_pd(oldpars, 'rimA', name)
-        _remove_pd(oldpars, 'rimB', name)
-        _remove_pd(oldpars, 'rimC', name)
-    elif name == 'rpa':
-        # convert scattering lengths from femtometers to centimeters
-        for p in "La", "Lb", "Lc", "Ld":
-            if p in oldpars: oldpars[p] *= 1e-13
 
-    return oldname, oldpars
+    # If it is a product model P*S, then check the individual forms for special
+    # cases.  Note: despite the structure factor alone not having scale or
+    # background, the product model does, so this is below the test for
+    # models without scale or background.
+    namelist = name.split('*') if '*' in name else [name]
+    for name in namelist:
+        if name == 'pearl_necklace':
+            _remove_pd(oldpars, 'num_pearls', name)
+            _remove_pd(oldpars, 'thick_string', name)
+        elif name == 'core_shell_parallelepiped':
+            _remove_pd(oldpars, 'rimA', name)
+            _remove_pd(oldpars, 'rimB', name)
+            _remove_pd(oldpars, 'rimC', name)
+        elif name == 'rpa':
+            # convert scattering lengths from femtometers to centimeters
+            for p in "La", "Lb", "Lc", "Ld":
+                if p in oldpars: oldpars[p] *= 1e-13
 
-def constrain_new_to_old(model_definition, pars):
+    return oldpars
+
+def constrain_new_to_old(model_info, pars):
     """
     Restrict parameter values to those that will match sasview.
     """
+    name = model_info['id']
     # Note: update convert.revert_model to match
-    name = model_definition.name
     if name in MODELS_WITHOUT_SCALE:
         pars['scale'] = 1
-    elif name in MODELS_WITHOUT_BACKGROUND:
+    if name in MODELS_WITHOUT_BACKGROUND:
         pars['background'] = 0
-    elif name == 'pearl_necklace':
-        pars['string_thickness_pd_n'] = 0
-        pars['number_of_pearls_pd_n'] = 0
-    elif name == 'line':
-        pars['scale'] = 1
-        pars['background'] = 0
-    elif name == 'rpa':
-        pars['case_num'] = int(pars['case_num'])
-    elif getattr(model_definition, 'category', None) == 'structure-factor':
-        pars['scale'], pars['background'] = 1, 0
 
+    # If it is a product model P*S, then check the individual forms for special
+    # cases.  Note: despite the structure factor alone not having scale or
+    # background, the product model does, so this is below the test for
+    # models without scale or background.
+    namelist = name.split('*') if '*' in name else [name]
+    for name in namelist:
+        if name == 'pearl_necklace':
+            pars['string_thickness_pd_n'] = 0
+            pars['number_of_pearls_pd_n'] = 0
+        elif name == 'line':
+            pars['scale'] = 1
+            pars['background'] = 0
+        elif name == 'rpa':
+            pars['case_num'] = int(pars['case_num'])
Index: sasmodels/core.py
===================================================================
--- sasmodels/core.py	(revision d18582e2d66b12afb03e1c3a7c0af6e4449c6bfe)
+++ sasmodels/core.py	(revision 17bbadde9de73711781f748816b06e6956dc8cfa)
@@ -21,6 +21,6 @@
 
 __all__ = [
-    "list_models", "load_model_definition", "precompile_dll",
-    "load_model", "make_kernel", "call_kernel", "call_ER", "call_VR",
+    "list_models", "load_model_info", "precompile_dll",
+    "build_model", "make_kernel", "call_kernel", "call_ER_VR",
 ]
 
@@ -35,5 +35,5 @@
 
 
-def load_model_definition(model_name):
+def load_model_info(model_name):
     """
     Load a model definition given the model name.
@@ -43,6 +43,6 @@
     """
     __import__('sasmodels.models.'+model_name)
-    model_definition = getattr(models, model_name, None)
-    return model_definition
+    kernel_module = getattr(models, model_name, None)
+    return generate.make_model_info(kernel_module)
 
 
@@ -51,5 +51,6 @@
     Precompile the dll for a model.
 
-    Returns the path to the compiled model.
+    Returns the path to the compiled model, or None if the model is a pure
+    python model.
 
     This can be used when build the windows distribution of sasmodels
@@ -60,7 +61,7 @@
     dll path and the allowed floating point precision.
     """
-    model_definition = load_model_definition(model_name)
-    source, info = generate.make(model_definition)
-    return kerneldll.make_dll(source, info, dtype=dtype)
+    model_info = load_model_info(model_name)
+    source = generate.make_source(model_info)
+    return kerneldll.make_dll(source, model_info, dtype=dtype) if source else None
 
 
@@ -73,5 +74,5 @@
     return True
 
-def load_model(model_definition, dtype=None, platform="ocl"):
+def build_model(model_info, dtype=None, platform="ocl"):
     """
     Prepare the model for the default execution platform.
@@ -80,7 +81,6 @@
     on the model and the computing platform.
 
-    *model_definition* is the python module which defines the model.  If the
-    model name is given instead, then :func:`load_model_definition` will be
-    called with the model name.
+    *model_info* is the model definition structure returned from
+    :func:`load_model_info`.
 
     *dtype* indicates whether the model should use single or double precision
@@ -93,11 +93,9 @@
     otherwise it uses the default "ocl".
     """
-    if isstr(model_definition):
-        model_definition = load_model_definition(model_definition)
+    source = generate.make_source(model_info)
     if dtype is None:
-        dtype = 'single' if getattr(model_definition, 'single', True) else 'double'
-    source, info = generate.make(model_definition)
-    if callable(info.get('Iq', None)):
-        return kernelpy.PyModel(info)
+        dtype = 'single' if model_info['single'] else 'double'
+    if callable(model_info.get('Iq', None)):
+        return kernelpy.PyModel(model_info)
 
     ## for debugging:
@@ -107,13 +105,13 @@
     ##  4. rerun "python -m sasmodels.direct_model $MODELNAME"
     ##  5. uncomment open().read() so that source will be regenerated from model
-    # open(info['name']+'.c','w').write(source)
-    # source = open(info['name']+'.cl','r').read()
+    # open(model_info['name']+'.c','w').write(source)
+    # source = open(model_info['name']+'.cl','r').read()
 
     if (platform == "dll"
             or not HAVE_OPENCL
             or not kernelcl.environment().has_type(dtype)):
-        return kerneldll.load_dll(source, info, dtype)
+        return kerneldll.load_dll(source, model_info, dtype)
     else:
-        return kernelcl.GpuModel(source, info, dtype)
+        return kernelcl.GpuModel(source, model_info, dtype)
 
 def make_kernel(model, q_vectors):
@@ -123,5 +121,5 @@
     return model(q_vectors)
 
-def get_weights(info, pars, name):
+def get_weights(model_info, pars, name):
     """
     Generate the distribution for parameter *name* given the parameter values
@@ -131,8 +129,8 @@
     from the *pars* dictionary for parameter value and parameter dispersion.
     """
-    relative = name in info['partype']['pd-rel']
-    limits = info['limits'][name]
+    relative = name in model_info['partype']['pd-rel']
+    limits = model_info['limits'][name]
     disperser = pars.get(name+'_pd_type', 'gaussian')
-    value = pars.get(name, info['defaults'][name])
+    value = pars.get(name, model_info['defaults'][name])
     npts = pars.get(name+'_pd_n', 0)
     width = pars.get(name+'_pd', 0.0)
@@ -173,8 +171,28 @@
     return kernel(fixed_pars, pd_pars, cutoff=cutoff)
 
+def call_ER_VR(model_info, vol_pars):
+    """
+    Return effect radius and volume ratio for the model.
+
+    *info* is either *kernel.info* for *kernel=make_kernel(model,q)*
+    or *model.info*.
+
+    *pars* are the parameters as expected by :func:`call_kernel`.
+    """
+    ER = model_info.get('ER', None)
+    VR = model_info.get('VR', None)
+    value, weight = dispersion_mesh(vol_pars)
+
+    individual_radii = ER(*value) if ER else 1.0
+    whole, part = VR(*value) if VR else (1.0, 1.0)
+
+    effect_radius = np.sum(weight*individual_radii) / np.sum(weight)
+    volume_ratio = np.sum(weight*part)/np.sum(weight*whole)
+    return effect_radius, volume_ratio
+
+
 def call_ER(info, pars):
     """
     Call the model ER function using *pars*.
-
     *info* is either *model.info* if you have a loaded model, or *kernel.info*
     if you have a model kernel prepared for evaluation.
@@ -194,5 +212,4 @@
     """
     Call the model VR function using *pars*.
-
     *info* is either *model.info* if you have a loaded model, or *kernel.info*
     if you have a model kernel prepared for evaluation.
@@ -208,2 +225,4 @@
         return np.sum(weight*part)/np.sum(weight*whole)
 
+# TODO: remove call_ER, call_VR
+
Index: sasmodels/direct_model.py
===================================================================
--- sasmodels/direct_model.py	(revision d18582e2d66b12afb03e1c3a7c0af6e4449c6bfe)
+++ sasmodels/direct_model.py	(revision 17bbadde9de73711781f748816b06e6956dc8cfa)
@@ -25,6 +25,6 @@
 import numpy as np
 
-from .core import load_model_definition, load_model, make_kernel
-from .core import call_kernel, call_ER, call_VR
+from .core import make_kernel
+from .core import call_kernel, call_ER_VR
 from . import sesans
 from . import resolution
@@ -180,20 +180,9 @@
         return self._calc_theory(pars, cutoff=self.cutoff)
 
-    def ER(self, **pars):
-        """
-        Compute the equivalent radius for the model.
-
-        Return 0. if not defined.
-        """
-        return call_ER(self.model.info, pars)
-
-    def VR(self, **pars):
-        """
-        Compute the equivalent volume for the model, including polydispersity
-        effects.
-
-        Return 1. if not defined.
-        """
-        return call_VR(self.model.info, pars)
+    def ER_VR(self, **pars):
+        """
+        Compute the equivalent radius and volume ratio for the model.
+        """
+        return call_ER_VR(self.model.info, pars)
 
     def simulate_data(self, noise=None, **pars):
@@ -210,4 +199,5 @@
     import sys
     from .data import empty_data1D, empty_data2D
+    from .core import load_model_info, build_model
 
     if len(sys.argv) < 3:
@@ -216,5 +206,5 @@
     model_name = sys.argv[1]
     call = sys.argv[2].upper()
-    if call not in ("ER", "VR"):
+    if call != "ER_VR":
         try:
             values = [float(v) for v in call.split(',')]
@@ -233,14 +223,12 @@
         data = empty_data1D([0.001])  # Data not used in ER/VR
 
-    model_definition = load_model_definition(model_name)
-    model = load_model(model_definition)
+    model_info = load_model_info(model_name)
+    model = build_model(model_info)
     calculator = DirectModel(data, model)
     pars = dict((k, float(v))
                 for pair in sys.argv[3:]
                 for k, v in [pair.split('=')])
-    if call == "ER":
-        print(calculator.ER(**pars))
-    elif call == "VR":
-        print(calculator.VR(**pars))
+    if call == "ER_VR":
+        print(calculator.ER_VR(**pars))
     else:
         Iq = calculator(**pars)
Index: sasmodels/generate.py
===================================================================
--- sasmodels/generate.py	(revision 5ceb7d086567a20a3ec80bb8a5fc53559c30fa40)
+++ sasmodels/generate.py	(revision 17bbadde9de73711781f748816b06e6956dc8cfa)
@@ -117,9 +117,11 @@
     are added to the beginning of the parameter list.  They will show up
     in the documentation as model parameters, but they are never sent to
-    the kernel functions.
-
-    *category* is the default category for the model.  Models in the
-    *structure-factor* category do not have *scale* and *background*
-    added.
+    the kernel functions.  Note that *effect_radius* and *volfraction*
+    must occur first in structure factor calculations.
+
+    *category* is the default category for the model.  The category is
+    two level structure, with the form "group:section", indicating where
+    in the manual the model will be located.  Models are alphabetical
+    within their section.
 
     *source* is the list of C-99 source files that must be joined to
@@ -157,5 +159,5 @@
 
 
-An *info* dictionary is constructed from the kernel meta data and
+An *model_info* dictionary is constructed from the kernel meta data and
 returned to the caller.
 
@@ -190,5 +192,5 @@
 
 The function :func:`make` loads the metadata from the module and returns
-the kernel source.  The function :func:`doc` extracts the doc string
+the kernel source.  The function :func:`make_doc` extracts the doc string
 and adds the parameter table to the top.  The function :func:`model_sources`
 returns a list of files required by the model.
@@ -217,5 +219,6 @@
 import numpy as np
 
-#__all__ = ["make", "doc", "model_sources", "convert_type"]
+#TODO: determine which functions are useful outside of generate
+#__all__ = ["model_info", "make_doc", "make_source", "convert_type"]
 
 C_KERNEL_TEMPLATE_PATH = joinpath(dirname(__file__), 'kernel_template.c')
@@ -327,11 +330,11 @@
     raise ValueError("%r not found in %s" % (filename, search_path))
 
-def model_sources(info):
+def model_sources(model_info):
     """
     Return a list of the sources file paths for the module.
     """
-    search_path = [dirname(info['filename']),
+    search_path = [dirname(model_info['filename']),
                    abspath(joinpath(dirname(__file__), 'models'))]
-    return [_search(search_path, f) for f in info['source']]
+    return [_search(search_path, f) for f in model_info['source']]
 
 # Pragmas for enable OpenCL features.  Be sure to protect them so that they
@@ -391,10 +394,183 @@
 
 
-def kernel_name(info, is_2d):
+def kernel_name(model_info, is_2d):
     """
     Name of the exported kernel symbol.
     """
-    return info['name'] + "_" + ("Iqxy" if is_2d else "Iq")
-
+    return model_info['name'] + "_" + ("Iqxy" if is_2d else "Iq")
+
+
+def indent(s, depth):
+    """
+    Indent a string of text with *depth* additional spaces on each line.
+    """
+    spaces = " "*depth
+    sep = "\n" + spaces
+    return spaces + sep.join(s.split("\n"))
+
+
+LOOP_OPEN = """\
+for (int %(name)s_i=0; %(name)s_i < N%(name)s; %(name)s_i++) {
+  const double %(name)s = loops[2*(%(name)s_i%(offset)s)];
+  const double %(name)s_w = loops[2*(%(name)s_i%(offset)s)+1];\
+"""
+def build_polydispersity_loops(pd_pars):
+    """
+    Build polydispersity loops
+
+    Returns loop opening and loop closing
+    """
+    depth = 4
+    offset = ""
+    loop_head = []
+    loop_end = []
+    for name in pd_pars:
+        subst = {'name': name, 'offset': offset}
+        loop_head.append(indent(LOOP_OPEN % subst, depth))
+        loop_end.insert(0, (" "*depth) + "}")
+        offset += '+N' + name
+        depth += 2
+    return "\n".join(loop_head), "\n".join(loop_end)
+
+C_KERNEL_TEMPLATE = None
+def make_source(model_info):
+    """
+    Generate the OpenCL/ctypes kernel from the module info.
+
+    Uses source files found in the given search path.
+    """
+    if callable(model_info['Iq']):
+        return None
+
+    # TODO: need something other than volume to indicate dispersion parameters
+    # No volume normalization despite having a volume parameter.
+    # Thickness is labelled a volume in order to trigger polydispersity.
+    # May want a separate dispersion flag, or perhaps a separate category for
+    # disperse, but not volume.  Volume parameters also use relative values
+    # for the distribution rather than the absolute values used by angular
+    # dispersion.  Need to be careful that necessary parameters are available
+    # for computing volume even if we allow non-disperse volume parameters.
+
+    # Load template
+    global C_KERNEL_TEMPLATE
+    if C_KERNEL_TEMPLATE is None:
+        with open(C_KERNEL_TEMPLATE_PATH) as fid:
+            C_KERNEL_TEMPLATE = fid.read()
+
+    # Load additional sources
+    source = [open(f).read() for f in model_sources(model_info)]
+
+    # Prepare defines
+    defines = []
+    partype = model_info['partype']
+    pd_1d = partype['pd-1d']
+    pd_2d = partype['pd-2d']
+    fixed_1d = partype['fixed-1d']
+    fixed_2d = partype['fixed-1d']
+
+    iq_parameters = [p[0]
+                     for p in model_info['parameters'][2:]  # skip scale, background
+                     if p[0] in set(fixed_1d + pd_1d)]
+    iqxy_parameters = [p[0]
+                       for p in model_info['parameters'][2:]  # skip scale, background
+                       if p[0] in set(fixed_2d + pd_2d)]
+    volume_parameters = [p[0]
+                         for p in model_info['parameters']
+                         if p[4] == 'volume']
+
+    # Fill in defintions for volume parameters
+    if volume_parameters:
+        defines.append(('VOLUME_PARAMETERS',
+                        ','.join(volume_parameters)))
+        defines.append(('VOLUME_WEIGHT_PRODUCT',
+                        '*'.join(p + '_w' for p in volume_parameters)))
+
+    # Generate form_volume function from body only
+    if model_info['form_volume'] is not None:
+        if volume_parameters:
+            vol_par_decl = ', '.join('double ' + p for p in volume_parameters)
+        else:
+            vol_par_decl = 'void'
+        defines.append(('VOLUME_PARAMETER_DECLARATIONS',
+                        vol_par_decl))
+        fn = """\
+double form_volume(VOLUME_PARAMETER_DECLARATIONS);
+double form_volume(VOLUME_PARAMETER_DECLARATIONS) {
+    %(body)s
+}
+""" % {'body':model_info['form_volume']}
+        source.append(fn)
+
+    # Fill in definitions for Iq parameters
+    defines.append(('IQ_KERNEL_NAME', model_info['name'] + '_Iq'))
+    defines.append(('IQ_PARAMETERS', ', '.join(iq_parameters)))
+    if fixed_1d:
+        defines.append(('IQ_FIXED_PARAMETER_DECLARATIONS',
+                        ', \\\n    '.join('const double %s' % p for p in fixed_1d)))
+    if pd_1d:
+        defines.append(('IQ_WEIGHT_PRODUCT',
+                        '*'.join(p + '_w' for p in pd_1d)))
+        defines.append(('IQ_DISPERSION_LENGTH_DECLARATIONS',
+                        ', \\\n    '.join('const int N%s' % p for p in pd_1d)))
+        defines.append(('IQ_DISPERSION_LENGTH_SUM',
+                        '+'.join('N' + p for p in pd_1d)))
+        open_loops, close_loops = build_polydispersity_loops(pd_1d)
+        defines.append(('IQ_OPEN_LOOPS',
+                        open_loops.replace('\n', ' \\\n')))
+        defines.append(('IQ_CLOSE_LOOPS',
+                        close_loops.replace('\n', ' \\\n')))
+    if model_info['Iq'] is not None:
+        defines.append(('IQ_PARAMETER_DECLARATIONS',
+                        ', '.join('double ' + p for p in iq_parameters)))
+        fn = """\
+double Iq(double q, IQ_PARAMETER_DECLARATIONS);
+double Iq(double q, IQ_PARAMETER_DECLARATIONS) {
+    %(body)s
+}
+""" % {'body':model_info['Iq']}
+        source.append(fn)
+
+    # Fill in definitions for Iqxy parameters
+    defines.append(('IQXY_KERNEL_NAME', model_info['name'] + '_Iqxy'))
+    defines.append(('IQXY_PARAMETERS', ', '.join(iqxy_parameters)))
+    if fixed_2d:
+        defines.append(('IQXY_FIXED_PARAMETER_DECLARATIONS',
+                        ', \\\n    '.join('const double %s' % p for p in fixed_2d)))
+    if pd_2d:
+        defines.append(('IQXY_WEIGHT_PRODUCT',
+                        '*'.join(p + '_w' for p in pd_2d)))
+        defines.append(('IQXY_DISPERSION_LENGTH_DECLARATIONS',
+                        ', \\\n    '.join('const int N%s' % p for p in pd_2d)))
+        defines.append(('IQXY_DISPERSION_LENGTH_SUM',
+                        '+'.join('N' + p for p in pd_2d)))
+        open_loops, close_loops = build_polydispersity_loops(pd_2d)
+        defines.append(('IQXY_OPEN_LOOPS',
+                        open_loops.replace('\n', ' \\\n')))
+        defines.append(('IQXY_CLOSE_LOOPS',
+                        close_loops.replace('\n', ' \\\n')))
+    if model_info['Iqxy'] is not None:
+        defines.append(('IQXY_PARAMETER_DECLARATIONS',
+                        ', '.join('double ' + p for p in iqxy_parameters)))
+        fn = """\
+double Iqxy(double qx, double qy, IQXY_PARAMETER_DECLARATIONS);
+double Iqxy(double qx, double qy, IQXY_PARAMETER_DECLARATIONS) {
+    %(body)s
+}
+""" % {'body':model_info['Iqxy']}
+        source.append(fn)
+
+    # Need to know if we have a theta parameter for Iqxy; it is not there
+    # for the magnetic sphere model, for example, which has a magnetic
+    # orientation but no shape orientation.
+    if 'theta' in pd_2d:
+        defines.append(('IQXY_HAS_THETA', '1'))
+
+    #for d in defines: print(d)
+    defines = '\n'.join('#define %s %s' % (k, v) for k, v in defines)
+    sources = '\n\n'.join(source)
+    return C_KERNEL_TEMPLATE % {
+        'DEFINES': defines,
+        'SOURCES': sources,
+        }
 
 def categorize_parameters(pars):
@@ -403,4 +579,23 @@
 
     Returns a dictionary of categories.
+
+    Note: these categories are subject to change, depending on the needs of
+    the UI and the needs of the kernel calling function.
+
+    The categories are as follows:
+
+    * *volume* list of volume parameter names
+    * *orientation* list of orientation parameters
+    * *magnetic* list of magnetic parameters
+    * *<empty string>* list of parameters that have no type info
+
+    Each parameter is in one and only one category.
+
+    The following derived categories are created:
+
+    * *fixed-1d* list of non-polydisperse parameters for 1D models
+    * *pd-1d* list of polydisperse parameters for 1D models
+    * *fixed-2d* list of non-polydisperse parameters for 2D models
+    * *pd-d2* list of polydisperse parameters for 2D models
     """
     partype = {
@@ -429,177 +624,58 @@
     return partype
 
-def indent(s, depth):
-    """
-    Indent a string of text with *depth* additional spaces on each line.
-    """
-    spaces = " "*depth
-    sep = "\n" + spaces
-    return spaces + sep.join(s.split("\n"))
-
-
-LOOP_OPEN = """\
-for (int %(name)s_i=0; %(name)s_i < N%(name)s; %(name)s_i++) {
-  const double %(name)s = loops[2*(%(name)s_i%(offset)s)];
-  const double %(name)s_w = loops[2*(%(name)s_i%(offset)s)+1];\
-"""
-def build_polydispersity_loops(pd_pars):
-    """
-    Build polydispersity loops
-
-    Returns loop opening and loop closing
-    """
-    depth = 4
-    offset = ""
-    loop_head = []
-    loop_end = []
-    for name in pd_pars:
-        subst = {'name': name, 'offset': offset}
-        loop_head.append(indent(LOOP_OPEN % subst, depth))
-        loop_end.insert(0, (" "*depth) + "}")
-        offset += '+N' + name
-        depth += 2
-    return "\n".join(loop_head), "\n".join(loop_end)
-
-C_KERNEL_TEMPLATE = None
-def make_model(info):
-    """
-    Generate the code for the kernel defined by info, using source files
-    found in the given search path.
-    """
-    # TODO: need something other than volume to indicate dispersion parameters
-    # No volume normalization despite having a volume parameter.
-    # Thickness is labelled a volume in order to trigger polydispersity.
-    # May want a separate dispersion flag, or perhaps a separate category for
-    # disperse, but not volume.  Volume parameters also use relative values
-    # for the distribution rather than the absolute values used by angular
-    # dispersion.  Need to be careful that necessary parameters are available
-    # for computing volume even if we allow non-disperse volume parameters.
-
-    # Load template
-    global C_KERNEL_TEMPLATE
-    if C_KERNEL_TEMPLATE is None:
-        with open(C_KERNEL_TEMPLATE_PATH) as fid:
-            C_KERNEL_TEMPLATE = fid.read()
-
-    # Load additional sources
-    source = [open(f).read() for f in model_sources(info)]
-
-    # Prepare defines
-    defines = []
-    partype = info['partype']
-    pd_1d = partype['pd-1d']
-    pd_2d = partype['pd-2d']
-    fixed_1d = partype['fixed-1d']
-    fixed_2d = partype['fixed-1d']
-
-    iq_parameters = [p[0]
-                     for p in info['parameters'][2:] # skip scale, background
-                     if p[0] in set(fixed_1d + pd_1d)]
-    iqxy_parameters = [p[0]
-                       for p in info['parameters'][2:] # skip scale, background
-                       if p[0] in set(fixed_2d + pd_2d)]
-    volume_parameters = [p[0]
-                         for p in info['parameters']
-                         if p[4] == 'volume']
-
-    # Fill in defintions for volume parameters
-    if volume_parameters:
-        defines.append(('VOLUME_PARAMETERS',
-                        ','.join(volume_parameters)))
-        defines.append(('VOLUME_WEIGHT_PRODUCT',
-                        '*'.join(p + '_w' for p in volume_parameters)))
-
-    # Generate form_volume function from body only
-    if info['form_volume'] is not None:
-        if volume_parameters:
-            vol_par_decl = ', '.join('double ' + p for p in volume_parameters)
-        else:
-            vol_par_decl = 'void'
-        defines.append(('VOLUME_PARAMETER_DECLARATIONS',
-                        vol_par_decl))
-        fn = """\
-double form_volume(VOLUME_PARAMETER_DECLARATIONS);
-double form_volume(VOLUME_PARAMETER_DECLARATIONS) {
-    %(body)s
-}
-""" % {'body':info['form_volume']}
-        source.append(fn)
-
-    # Fill in definitions for Iq parameters
-    defines.append(('IQ_KERNEL_NAME', info['name'] + '_Iq'))
-    defines.append(('IQ_PARAMETERS', ', '.join(iq_parameters)))
-    if fixed_1d:
-        defines.append(('IQ_FIXED_PARAMETER_DECLARATIONS',
-                        ', \\\n    '.join('const double %s' % p for p in fixed_1d)))
-    if pd_1d:
-        defines.append(('IQ_WEIGHT_PRODUCT',
-                        '*'.join(p + '_w' for p in pd_1d)))
-        defines.append(('IQ_DISPERSION_LENGTH_DECLARATIONS',
-                        ', \\\n    '.join('const int N%s' % p for p in pd_1d)))
-        defines.append(('IQ_DISPERSION_LENGTH_SUM',
-                        '+'.join('N' + p for p in pd_1d)))
-        open_loops, close_loops = build_polydispersity_loops(pd_1d)
-        defines.append(('IQ_OPEN_LOOPS',
-                        open_loops.replace('\n', ' \\\n')))
-        defines.append(('IQ_CLOSE_LOOPS',
-                        close_loops.replace('\n', ' \\\n')))
-    if info['Iq'] is not None:
-        defines.append(('IQ_PARAMETER_DECLARATIONS',
-                        ', '.join('double ' + p for p in iq_parameters)))
-        fn = """\
-double Iq(double q, IQ_PARAMETER_DECLARATIONS);
-double Iq(double q, IQ_PARAMETER_DECLARATIONS) {
-    %(body)s
-}
-""" % {'body':info['Iq']}
-        source.append(fn)
-
-    # Fill in definitions for Iqxy parameters
-    defines.append(('IQXY_KERNEL_NAME', info['name'] + '_Iqxy'))
-    defines.append(('IQXY_PARAMETERS', ', '.join(iqxy_parameters)))
-    if fixed_2d:
-        defines.append(('IQXY_FIXED_PARAMETER_DECLARATIONS',
-                        ', \\\n    '.join('const double %s' % p for p in fixed_2d)))
-    if pd_2d:
-        defines.append(('IQXY_WEIGHT_PRODUCT',
-                        '*'.join(p + '_w' for p in pd_2d)))
-        defines.append(('IQXY_DISPERSION_LENGTH_DECLARATIONS',
-                        ', \\\n    '.join('const int N%s' % p for p in pd_2d)))
-        defines.append(('IQXY_DISPERSION_LENGTH_SUM',
-                        '+'.join('N' + p for p in pd_2d)))
-        open_loops, close_loops = build_polydispersity_loops(pd_2d)
-        defines.append(('IQXY_OPEN_LOOPS',
-                        open_loops.replace('\n', ' \\\n')))
-        defines.append(('IQXY_CLOSE_LOOPS',
-                        close_loops.replace('\n', ' \\\n')))
-    if info['Iqxy'] is not None:
-        defines.append(('IQXY_PARAMETER_DECLARATIONS',
-                        ', '.join('double ' + p for p in iqxy_parameters)))
-        fn = """\
-double Iqxy(double qx, double qy, IQXY_PARAMETER_DECLARATIONS);
-double Iqxy(double qx, double qy, IQXY_PARAMETER_DECLARATIONS) {
-    %(body)s
-}
-""" % {'body':info['Iqxy']}
-        source.append(fn)
-
-    # Need to know if we have a theta parameter for Iqxy; it is not there
-    # for the magnetic sphere model, for example, which has a magnetic
-    # orientation but no shape orientation.
-    if 'theta' in pd_2d:
-        defines.append(('IQXY_HAS_THETA', '1'))
-
-    #for d in defines: print(d)
-    defines = '\n'.join('#define %s %s' % (k, v) for k, v in defines)
-    sources = '\n\n'.join(source)
-    return C_KERNEL_TEMPLATE % {
-        'DEFINES': defines,
-        'SOURCES': sources,
-        }
-
-def make_info(kernel_module):
+def process_parameters(model_info):
+    """
+    Process parameter block, precalculating parameter details.
+    """
+    # Fill in the derived attributes
+    model_info['limits'] = dict((p[0], p[3]) for p in model_info['parameters'])
+    model_info['partype'] = categorize_parameters(model_info['parameters'])
+    model_info['defaults'] = dict((p[0], p[2]) for p in model_info['parameters'])
+    if model_info.get('demo', None) is None:
+        model_info['demo'] = model_info['defaults']
+
+def make_model_info(kernel_module):
     """
     Interpret the model definition file, categorizing the parameters.
-    """
+
+    The module can be loaded with a normal python import statement if you
+    know which module you need, or with __import__('sasmodels.model.'+name)
+    if the name is in a string.
+
+    The *model_info* structure contains the following fields:
+
+    * *id* is the id of the kernel
+    * *name* is the display name of the kernel
+    * *title* is a short description of the kernel
+    * *description* is a long description of the kernel (this doesn't seem
+      very useful since the Help button on the model page brings you directly
+      to the documentation page)
+    * *docs* is the docstring from the module.  Use :func:`make_doc` to
+    * *category* specifies the model location in the docs
+    * *parameters* is the model parameter table
+    * *single* is True if the model allows single precision
+    * *defaults* is the *{parameter: value}* table built from the parameter
+      description table.
+    * *limits* is the *{parameter: [min, max]}* table built from the
+      parameter description table.
+    * *partypes* categorizes the model parameters. See
+      :func:`categorize_parameters` for details.
+    * *demo* contains the *{parameter: value}* map used in compare (and maybe
+      for the demo plot, if plots aren't set up to use the default values).
+      If *demo* is not given in the file, then the default values will be used.
+    * *tests* is a set of tests that must pass
+    * *source* is the list of library files to include in the C model build
+    * *Iq*, *Iqxy*, *form_volume*, *ER*, and *VR* are python functions
+      implementing the kernel for the module, or None if they are not
+      defined in python
+    * *oldname* is the model name in pre-4.0 Sasview
+    * *oldpars* is the *{new: old}* parameter translation table
+      from pre-4.0 Sasview
+    * *composition* is None if the model is independent, otherwise it is a
+      tuple with composition type ('product' or 'mixture') and a list of
+      *model_info* blocks for the composition objects.  This allows us to
+      build complete product and mixture models from just the info.
+    """
+    # TODO: maybe turn model_info into a class ModelDefinition
     #print(kernelfile)
     category = getattr(kernel_module, 'category', None)
@@ -608,12 +684,11 @@
     # parameters if an explicit demo parameter set has not been specified.
     demo_parameters = getattr(kernel_module, 'demo', None)
-    if demo_parameters is None:
-        demo_parameters = dict((p[0], p[2]) for p in parameters)
     filename = abspath(kernel_module.__file__)
     kernel_id = splitext(basename(filename))[0]
     name = getattr(kernel_module, 'name', None)
+    single = getattr(kernel_module, 'single', True)
     if name is None:
         name = " ".join(w.capitalize() for w in kernel_id.split('_'))
-    info = dict(
+    model_info = dict(
         id=kernel_id,  # string used to load the kernel
         filename=abspath(kernel_module.__file__),
@@ -621,32 +696,20 @@
         title=kernel_module.title,
         description=kernel_module.description,
+        docs=kernel_module.__doc__,
         category=category,
         parameters=parameters,
+        composition=None,
+        single=single,
         demo=demo_parameters,
         source=getattr(kernel_module, 'source', []),
-        oldname=kernel_module.oldname,
-        oldpars=kernel_module.oldpars,
+        oldname=getattr(kernel_module, 'oldname', None),
+        oldpars=getattr(kernel_module, 'oldpars', {}),
+        tests=getattr(kernel_module, 'tests', []),
         )
+    process_parameters(model_info)
     # Fill in attributes which default to None
-    info.update((k, getattr(kernel_module, k, None))
-                for k in ('ER', 'VR', 'form_volume', 'Iq', 'Iqxy'))
-    # Fill in the derived attributes
-    info['limits'] = dict((p[0], p[3]) for p in info['parameters'])
-    info['partype'] = categorize_parameters(info['parameters'])
-    info['defaults'] = dict((p[0], p[2]) for p in info['parameters'])
-    return info
-
-def make(kernel_module):
-    """
-    Build an OpenCL/ctypes function from the definition in *kernel_module*.
-
-    The module can be loaded with a normal python import statement if you
-    know which module you need, or with __import__('sasmodels.model.'+name)
-    if the name is in a string.
-    """
-    info = make_info(kernel_module)
-    # Assume if one part of the kernel is python then all parts are.
-    source = make_model(info) if not callable(info['Iq']) else None
-    return source, info
+    model_info.update((k, getattr(kernel_module, k, None))
+                      for k in ('ER', 'VR', 'form_volume', 'Iq', 'Iqxy'))
+    return model_info
 
 section_marker = re.compile(r'\A(?P<first>[%s])(?P=first)*\Z'
@@ -683,5 +746,5 @@
     return "\n".join(_convert_section_titles_to_boldface(s.split('\n')))
 
-def doc(kernel_module):
+def make_doc(model_info):
     """
     Return the documentation for the model.
@@ -689,12 +752,11 @@
     Iq_units = "The returned value is scaled to units of |cm^-1| |sr^-1|, absolute scale."
     Sq_units = "The returned value is a dimensionless structure factor, $S(q)$."
-    info = make_info(kernel_module)
-    is_Sq = ("structure-factor" in info['category'])
+    is_Sq = ("structure-factor" in model_info['category'])
     #docs = kernel_module.__doc__
-    docs = convert_section_titles_to_boldface(kernel_module.__doc__)
-    subst = dict(id=info['id'].replace('_', '-'),
-                 name=info['name'],
-                 title=info['title'],
-                 parameters=make_partable(info['parameters']),
+    docs = convert_section_titles_to_boldface(model_info['docs'])
+    subst = dict(id=model_info['id'].replace('_', '-'),
+                 name=model_info['name'],
+                 title=model_info['title'],
+                 parameters=make_partable(model_info['parameters']),
                  returns=Sq_units if is_Sq else Iq_units,
                  docs=docs)
@@ -710,5 +772,5 @@
     import datetime
     tic = datetime.datetime.now()
-    make(cylinder)
+    make_source(make_model_info(cylinder))
     toc = (datetime.datetime.now() - tic).total_seconds()
     print("time: %g"%toc)
@@ -725,5 +787,6 @@
         __import__('sasmodels.models.' + name)
         model = getattr(sasmodels.models, name)
-        source, _ = make(model)
+        model_info = make_model_info(model)
+        source = make_source(model_info)
         print(source)
 
Index: sasmodels/kernelcl.py
===================================================================
--- sasmodels/kernelcl.py	(revision e6a5556cf35ef5c00c42dc56786102efc2b7d5c1)
+++ sasmodels/kernelcl.py	(revision 17bbadde9de73711781f748816b06e6956dc8cfa)
@@ -289,6 +289,6 @@
     GPU wrapper for a single model.
 
-    *source* and *info* are the model source and interface as returned
-    from :func:`gen.make`.
+    *source* and *model_info* are the model source and interface as returned
+    from :func:`generate.make_source` and :func:`generate.make_model_info`.
 
     *dtype* is the desired model precision.  Any numpy dtype for single
@@ -300,6 +300,6 @@
     that the compiler is allowed to take shortcuts.
     """
-    def __init__(self, source, info, dtype=generate.F32):
-        self.info = info
+    def __init__(self, source, model_info, dtype=generate.F32):
+        self.info = model_info
         self.source = source
         self.dtype = generate.F32 if dtype == 'fast' else np.dtype(dtype)
@@ -356,4 +356,5 @@
     """
     def __init__(self, q_vectors, dtype=generate.F32):
+        # TODO: do we ever need double precision q?
         env = environment()
         self.nq = q_vectors[0].size
@@ -389,5 +390,5 @@
     *kernel* is the GpuKernel object to call
 
-    *info* is the module information
+    *model_info* is the module information
 
     *q_vectors* is the q vectors at which the kernel should be evaluated
@@ -403,12 +404,12 @@
     Call :meth:`release` when done with the kernel instance.
     """
-    def __init__(self, kernel, info, q_vectors, dtype):
+    def __init__(self, kernel, model_info, q_vectors, dtype):
         q_input = GpuInput(q_vectors, dtype)
         self.kernel = kernel
-        self.info = info
+        self.info = model_info
         self.res = np.empty(q_input.nq, q_input.dtype)
         dim = '2d' if q_input.is_2d else '1d'
-        self.fixed_pars = info['partype']['fixed-' + dim]
-        self.pd_pars = info['partype']['pd-' + dim]
+        self.fixed_pars = model_info['partype']['fixed-' + dim]
+        self.pd_pars = model_info['partype']['pd-' + dim]
 
         # Inputs and outputs for each kernel call
@@ -430,4 +431,5 @@
                 else np.float32)  # will never get here, so use np.float32
 
+        #print "pars", fixed_pars, pd_pars
         res_bi = self.res_b
         nq = np.uint32(self.q_input.nq)
Index: sasmodels/kerneldll.py
===================================================================
--- sasmodels/kerneldll.py	(revision eafc9fa8b200a3b754e7dafb960c8891361eacef)
+++ sasmodels/kerneldll.py	(revision 17bbadde9de73711781f748816b06e6956dc8cfa)
@@ -89,10 +89,10 @@
 
 
-def dll_path(info, dtype="double"):
-    """
-    Path to the compiled model defined by *info*.
+def dll_path(model_info, dtype="double"):
+    """
+    Path to the compiled model defined by *model_info*.
     """
     from os.path import join as joinpath, split as splitpath, splitext
-    basename = splitext(splitpath(info['filename'])[1])[0]
+    basename = splitext(splitpath(model_info['filename'])[1])[0]
     if np.dtype(dtype) == generate.F32:
         basename += "32"
@@ -104,5 +104,5 @@
 
 
-def make_dll(source, info, dtype="double"):
+def make_dll(source, model_info, dtype="double"):
     """
     Load the compiled model defined by *kernel_module*.
@@ -123,6 +123,6 @@
     models are allowed as DLLs.
     """
-    if callable(info.get('Iq', None)):
-        return PyModel(info)
+    if callable(model_info.get('Iq', None)):
+        return PyModel(model_info)
 
     dtype = np.dtype(dtype)
@@ -133,13 +133,13 @@
 
     if dtype == generate.F32: # 32-bit dll
-        tempfile_prefix = 'sas_'+info['name']+'32_'
+        tempfile_prefix = 'sas_' + model_info['name'] + '32_'
     elif dtype == generate.F64:
-        tempfile_prefix = 'sas_'+info['name']+'64_'
+        tempfile_prefix = 'sas_' + model_info['name'] + '64_'
     else:
-        tempfile_prefix = 'sas_'+info['name']+'128_'
+        tempfile_prefix = 'sas_' + model_info['name'] + '128_'
 
     source = generate.convert_type(source, dtype)
-    source_files = generate.model_sources(info) + [info['filename']]
-    dll = dll_path(info, dtype)
+    source_files = generate.model_sources(model_info) + [model_info['filename']]
+    dll = dll_path(model_info, dtype)
     newest = max(os.path.getmtime(f) for f in source_files)
     if not os.path.exists(dll) or os.path.getmtime(dll) < newest:
@@ -159,5 +159,5 @@
 
 
-def load_dll(source, info, dtype="double"):
+def load_dll(source, model_info, dtype="double"):
     """
     Create and load a dll corresponding to the source, info pair returned
@@ -167,6 +167,6 @@
     allowed floating point precision.
     """
-    filename = make_dll(source, info, dtype=dtype)
-    return DllModel(filename, info, dtype=dtype)
+    filename = make_dll(source, model_info, dtype=dtype)
+    return DllModel(filename, model_info, dtype=dtype)
 
 
@@ -178,5 +178,5 @@
     ctypes wrapper for a single model.
 
-    *source* and *info* are the model source and interface as returned
+    *source* and *model_info* are the model source and interface as returned
     from :func:`gen.make`.
 
@@ -188,6 +188,6 @@
     Call :meth:`release` when done with the kernel.
     """
-    def __init__(self, dllpath, info, dtype=generate.F32):
-        self.info = info
+    def __init__(self, dllpath, model_info, dtype=generate.F32):
+        self.info = model_info
         self.dllpath = dllpath
         self.dll = None
@@ -244,5 +244,5 @@
     *kernel* is the c function to call.
 
-    *info* is the module information
+    *model_info* is the module information
 
     *q_input* is the DllInput q vectors at which the kernel should be
@@ -257,12 +257,12 @@
     Call :meth:`release` when done with the kernel instance.
     """
-    def __init__(self, kernel, info, q_input):
-        self.info = info
+    def __init__(self, kernel, model_info, q_input):
+        self.info = model_info
         self.q_input = q_input
         self.kernel = kernel
         self.res = np.empty(q_input.nq, q_input.dtype)
         dim = '2d' if q_input.is_2d else '1d'
-        self.fixed_pars = info['partype']['fixed-'+dim]
-        self.pd_pars = info['partype']['pd-'+dim]
+        self.fixed_pars = model_info['partype']['fixed-' + dim]
+        self.pd_pars = model_info['partype']['pd-' + dim]
 
         # In dll kernel, but not in opencl kernel
Index: sasmodels/kernelpy.py
===================================================================
--- sasmodels/kernelpy.py	(revision eafc9fa8b200a3b754e7dafb960c8891361eacef)
+++ sasmodels/kernelpy.py	(revision 17bbadde9de73711781f748816b06e6956dc8cfa)
@@ -16,6 +16,6 @@
     Wrapper for pure python models.
     """
-    def __init__(self, info):
-        self.info = info
+    def __init__(self, model_info):
+        self.info = model_info
 
     def __call__(self, q_vectors):
@@ -68,5 +68,5 @@
     *kernel* is the DllKernel object to call.
 
-    *info* is the module information
+    *model_info* is the module information
 
     *q_input* is the DllInput q vectors at which the kernel should be
@@ -81,6 +81,6 @@
     Call :meth:`release` when done with the kernel instance.
     """
-    def __init__(self, kernel, info, q_input):
-        self.info = info
+    def __init__(self, kernel, model_info, q_input):
+        self.info = model_info
         self.q_input = q_input
         self.res = np.empty(q_input.nq, q_input.dtype)
@@ -106,10 +106,10 @@
         else:
             self.kernel = kernel
-        fixed_pars = info['partype']['fixed-' + dim]
-        pd_pars = info['partype']['pd-' + dim]
-        vol_pars = info['partype']['volume']
+        fixed_pars = model_info['partype']['fixed-' + dim]
+        pd_pars = model_info['partype']['pd-' + dim]
+        vol_pars = model_info['partype']['volume']
 
         # First two fixed pars are scale and background
-        pars = [p[0] for p in info['parameters'][2:]]
+        pars = [p[0] for p in model_info['parameters'][2:]]
         offset = len(self.q_input.q_vectors)
         self.args = self.q_input.q_vectors + [None] * len(pars)
Index: sasmodels/list_pars.py
===================================================================
--- sasmodels/list_pars.py	(revision 5c962dff13d0a0a2f6d779be8df775f540958dbc)
+++ sasmodels/list_pars.py	(revision 17bbadde9de73711781f748816b06e6956dc8cfa)
@@ -13,6 +13,5 @@
 import sys
 
-from .core import load_model_definition
-from .generate import make_info
+from .core import load_model_info
 from .compare import MODELS, columnize
 
@@ -25,7 +24,6 @@
     partable = {}
     for name in sorted(MODELS):
-        definition = load_model_definition(name)
-        info = make_info(definition)
-        for p in info['parameters']:
+        model_info = load_model_info(name)
+        for p in model_info['parameters']:
             pname = p[0]
             partable.setdefault(pname, [])
Index: sasmodels/model_test.py
===================================================================
--- sasmodels/model_test.py	(revision 13ed84c3c71257d2df9c2ed4b4e98c0bfa4d7d69)
+++ sasmodels/model_test.py	(revision 17bbadde9de73711781f748816b06e6956dc8cfa)
@@ -50,8 +50,9 @@
 import numpy as np
 
-from .core import list_models, load_model_definition, load_model, HAVE_OPENCL
+from .core import list_models, load_model_info, build_model, HAVE_OPENCL
 from .core import make_kernel, call_kernel, call_ER, call_VR
 from .exception import annotate_exception
 
+#TODO: rename to tests so that tab completion works better for models directory
 
 def make_suite(loaders, models):
@@ -76,5 +77,5 @@
     for model_name in models:
         if model_name in skip: continue
-        model_definition = load_model_definition(model_name)
+        model_info = load_model_info(model_name)
 
         #print('------')
@@ -85,10 +86,10 @@
         # don't try to call cl kernel since it will not be
         # available in some environmentes.
-        is_py = callable(getattr(model_definition, 'Iq', None))
+        is_py = callable(model_info['Iq'])
 
         if is_py:  # kernel implemented in python
             test_name = "Model: %s, Kernel: python"%model_name
             test_method_name = "test_%s_python" % model_name
-            test = ModelTestCase(test_name, model_definition,
+            test = ModelTestCase(test_name, model_info,
                                  test_method_name,
                                  platform="dll",  # so that
@@ -104,5 +105,5 @@
                 # single precision.  The choice is determined by the
                 # presence of *single=False* in the model file.
-                test = ModelTestCase(test_name, model_definition,
+                test = ModelTestCase(test_name, model_info,
                                      test_method_name,
                                      platform="ocl", dtype=None)
@@ -114,5 +115,5 @@
                 test_name = "Model: %s, Kernel: dll"%model_name
                 test_method_name = "test_%s_dll" % model_name
-                test = ModelTestCase(test_name, model_definition,
+                test = ModelTestCase(test_name, model_info,
                                      test_method_name,
                                      platform="dll",
@@ -132,8 +133,8 @@
         description file.
         """
-        def __init__(self, test_name, definition, test_method_name,
+        def __init__(self, test_name, model_info, test_method_name,
                      platform, dtype):
             self.test_name = test_name
-            self.definition = definition
+            self.info = model_info
             self.platform = platform
             self.dtype = dtype
@@ -150,8 +151,8 @@
                 ]
 
-            tests = getattr(self.definition, 'tests', [])
+            tests = self.info['tests']
             try:
-                model = load_model(self.definition, dtype=self.dtype,
-                                   platform=self.platform)
+                model = build_model(self.info, dtype=self.dtype,
+                                    platform=self.platform)
                 for test in smoke_tests + tests:
                     self._run_one_test(model, test)
Index: sasmodels/product.py
===================================================================
--- sasmodels/product.py	(revision 17bbadde9de73711781f748816b06e6956dc8cfa)
+++ sasmodels/product.py	(revision 17bbadde9de73711781f748816b06e6956dc8cfa)
@@ -0,0 +1,168 @@
+"""
+Product model
+-------------
+
+The product model multiplies the structure factor by the form factor,
+modulated by the effective radius of the form.  The resulting model
+has a attributes of both the model description (with parameters, etc.)
+and the module evaluator (with call, release, etc.).
+
+To use it, first load form factor P and structure factor S, then create
+*ProductModel(P, S)*.
+"""
+import numpy as np
+
+from .core import call_ER_VR
+from .generate import process_parameters
+
+SCALE=0
+BACKGROUND=1
+EFFECT_RADIUS=2
+VOLFRACTION=3
+
+def make_product_info(p_info, s_info):
+    """
+    Create info block for product model.
+    """
+    p_id, p_name, p_pars = p_info['id'], p_info['name'], p_info['parameters']
+    s_id, s_name, s_pars = s_info['id'], s_info['name'], s_info['parameters']
+    # We require models to start with scale and background
+    assert s_pars[SCALE][0] == 'scale'
+    assert s_pars[BACKGROUND][0] == 'background'
+    # We require structure factors to start with effect radius and volfraction
+    assert s_pars[EFFECT_RADIUS][0] == 'effect_radius'
+    assert s_pars[VOLFRACTION][0] == 'volfraction'
+    # Combine the parameter sets.  We are skipping the first three
+    # parameters of S since scale, background are defined in P and
+    # effect_radius is set from P.ER().
+    pars = p_pars + s_pars[3:]
+    # check for duplicates; can't use assertion since they may never be checked
+    if len(set(p[0] for p in pars)) != len(pars):
+        raise ValueError("Duplicate parameters in %s and %s"%(p_id))
+    # For comparison with sasview, determine the old parameters.
+    oldname = [p_info['oldname'], s_info['oldname']]
+    oldpars = {'scale':'scale_factor'}
+    oldpars.update(p_info['oldpars'])
+    oldpars.update(s_info['oldpars'])
+
+    model_info = {}
+    model_info['id'] = '*'.join((p_id, s_id))
+    model_info['name'] = ' X '.join((p_name, s_name))
+    model_info['filename'] = None
+    model_info['title'] = 'Product of %s and structure factor %s'%(p_name, s_name)
+    model_info['description'] = model_info['title']
+    model_info['category'] = "custom"
+    model_info['parameters'] = pars
+    # Remember the component info blocks so we can build the product model
+    model_info['composition'] = ('product', [p_info, s_info])
+    model_info['oldname'] = oldname
+    model_info['oldpars'] = oldpars
+    process_parameters(model_info)
+    return model_info
+
+class ProductModel(object):
+    def __init__(self, P, S):
+        self.P = P
+        self.S = S
+        self.info = make_product_info(P.info, S.info)
+
+    def __call__(self, q_vectors):
+        # Note: may be sending the q_vectors to the GPU twice even though they
+        # are only needed once.  It would mess up modularity quite a bit to
+        # handle this optimally, especially since there are many cases where
+        # separate q vectors are needed (e.g., form in python and structure
+        # in opencl; or both in opencl, but one in single precision and the
+        # other in double precision).
+        p_kernel = self.P(q_vectors)
+        s_kernel = self.S(q_vectors)
+        return ProductKernel(self.info, p_kernel, s_kernel)
+
+    def release(self):
+        """
+        Free resources associated with the model.
+        """
+        self.P.release()
+        self.S.release()
+
+
+class ProductKernel(object):
+    def __init__(self, model_info, p_kernel, s_kernel):
+        dim = '2d' if p_kernel.q_input.is_2d else '1d'
+
+        # Need to know if we want 2D and magnetic parameters when constructing
+        # a parameter map.
+        par_map = {}
+        p_info = p_kernel.info['partype']
+        s_info = s_kernel.info['partype']
+        vol_pars = set(p_info['volume'])
+        if dim == '2d':
+            num_p_fixed = len(p_info['fixed-2d'])
+            num_p_pd = len(p_info['pd-2d'])
+            num_s_fixed = len(s_info['fixed-2d'])
+            num_s_pd = len(s_info['pd-2d'])
+            # volume parameters are amongst the pd pars for P, not S
+            vol_par_idx = [k for k,v in enumerate(p_info['pd-2d'])
+                           if v in vol_pars]
+        else:
+            num_p_fixed = len(p_info['fixed-1d'])
+            num_p_pd = len(p_info['pd-1d'])
+            num_s_fixed = len(s_info['fixed-1d'])
+            num_s_pd = len(s_info['pd-1d'])
+            # volume parameters are amongst the pd pars for P, not S
+            vol_par_idx = [k for k,v in enumerate(p_info['pd-1d'])
+                           if v in vol_pars]
+
+        start = 0
+        par_map['p_fixed'] = np.arange(start, start+num_p_fixed)
+        # User doesn't set scale, background or effect_radius for S in P*S,
+        # so borrow values from end of p_fixed.  This makes volfraction the
+        # first S parameter.
+        start += num_p_fixed - 3
+        par_map['s_fixed'] = np.arange(start, start+num_s_fixed)
+        par_map['volfraction'] = num_p_fixed
+        start += num_s_fixed
+        # vol pars offset from the start of pd pars
+        par_map['vol_pars'] = [start+k for k in vol_par_idx]
+        par_map['p_pd'] = np.arange(start, start+num_p_pd)
+        start += num_p_pd
+        par_map['s_pd'] = np.arange(start, start+num_s_pd)  # should be empty...
+
+
+        self.fixed_pars = model_info['partype']['fixed-' + dim]
+        self.pd_pars = model_info['partype']['pd-' + dim]
+        self.info = model_info
+        self.p_kernel = p_kernel
+        self.s_kernel = s_kernel
+        self.par_map = par_map
+
+    def __call__(self, fixed_pars, pd_pars, cutoff=1e-5):
+        pars = fixed_pars + pd_pars
+        scale = pars[SCALE]
+        background = pars[BACKGROUND]
+        s_volfraction = pars[self.par_map['volfraction']]
+        p_fixed = [pars[k] for k in self.par_map['p_fixed']]
+        s_fixed = [pars[k] for k in self.par_map['s_fixed']]
+        p_pd = [pars[k] for k in self.par_map['p_pd']]
+        s_pd = [pars[k] for k in self.par_map['s_pd']]
+        vol_pars = [pars[k] for k in self.par_map['vol_pars']]
+        p_pars = dict(list(zip(self.fixed_pars, p_fixed))
+                      +list(zip(self.pd_pars, p_pd)))
+
+        effect_radius, vol_ratio = call_ER_VR(self.p_kernel.info, vol_pars)
+
+        p_fixed[SCALE] = s_volfraction
+        p_fixed[BACKGROUND] = 0.0
+        s_fixed[SCALE] = scale
+        s_fixed[BACKGROUND] = 0.0
+        s_fixed[EFFECT_RADIUS] = effect_radius
+        s_fixed[VOLFRACTION] = s_volfraction/vol_ratio
+
+        p_res = self.p_kernel(p_fixed, p_pd)
+        s_res = self.s_kernel(s_fixed, s_pd)
+
+        return p_res*s_res + background
+
+    def release(self):
+        self.p_kernel.release()
+        self.q_kernel.release()
+
Index: sasmodels/resolution.py
===================================================================
--- sasmodels/resolution.py	(revision 5925e909151e120132e72e91095ab06bad045240)
+++ sasmodels/resolution.py	(revision 17bbadde9de73711781f748816b06e6956dc8cfa)
@@ -1061,6 +1061,6 @@
     else:
         pars = {}
-    defn = core.load_model_definition(name)
-    model = core.load_model(defn)
+    model_info = core.load_model_info(name)
+    model = core.build_model(model_info)
 
     kernel = core.make_kernel(model, [resolution.q_calc])
Index: sasmodels/sasview_model.py
===================================================================
--- sasmodels/sasview_model.py	(revision eafc9fa8b200a3b754e7dafb960c8891361eacef)
+++ sasmodels/sasview_model.py	(revision 17bbadde9de73711781f748816b06e6956dc8cfa)
@@ -22,5 +22,5 @@
 from . import core
 
-def make_class(model_definition, dtype='single', namestyle='name'):
+def make_class(model_info, dtype='single', namestyle='name'):
     """
     Load the sasview model defined in *kernel_module*.
@@ -32,5 +32,5 @@
     compatible with SasView.
     """
-    model = core.load_model(model_definition, dtype=dtype)
+    model = core.build_model(model_info, dtype=dtype)
     def __init__(self, multfactor=1):
         SasviewModel.__init__(self, model)
