Index: sasmodels/bumps_model.py
===================================================================
--- sasmodels/bumps_model.py	(revision 21b116f73d822594145622e342e0308fc3ecd14a)
+++ sasmodels/bumps_model.py	(revision 6d6508e66dc590f2ad678c70ef6fc78c3d4e684c)
@@ -83,5 +83,5 @@
     pars = {}     # floating point parameters
     pd_types = {} # distribution names
-    for p in model_info['parameters'].call_parameters:
+    for p in model_info.parameters.call_parameters:
         value = kwargs.pop(p.name, p.default)
         pars[p.name] = Parameter.default(value, name=p.name, limits=p.limits)
Index: sasmodels/compare.py
===================================================================
--- sasmodels/compare.py	(revision 8bd7b77da137d537b08a21d51f0a8000689d4cc6)
+++ sasmodels/compare.py	(revision 6d6508e66dc590f2ad678c70ef6fc78c3d4e684c)
@@ -244,5 +244,5 @@
 
     # Find the parameter definition
-    for par in model_info['parameters'].call_parameters:
+    for par in model_info.parameters.call_parameters:
         if par.name == p:
             break
@@ -284,5 +284,5 @@
     cylinder radius in this case).
     """
-    name = model_info['id']
+    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.
@@ -318,5 +318,5 @@
     """
     lines = []
-    parameters = model_info['parameters']
+    parameters = model_info.parameters
     for p in parameters.user_parameters(pars, is2d):
         fields = dict(
@@ -368,6 +368,6 @@
 
     # grab the sasview model, or create it if it is a product model
-    if model_info['composition']:
-        composition_type, parts = model_info['composition']
+    if model_info.composition:
+        composition_type, parts = model_info.composition
         if composition_type == 'product':
             from sas.models.MultiplicationModel import MultiplicationModel
@@ -668,5 +668,5 @@
     # Get the default values for the parameters
     pars = {}
-    for p in model_info['parameters'].call_parameters:
+    for p in model_info.parameters.call_parameters:
         parts = [('', p.default)]
         if p.polydisperse:
@@ -683,5 +683,5 @@
     # Plug in values given in demo
     if use_demo:
-        pars.update(model_info['demo'])
+        pars.update(model_info.demo)
     return pars
 
@@ -891,5 +891,5 @@
         # Initialize parameter ranges, fixing the 2D parameters for 1D data.
         if not opts['is2d']:
-            for p in model_info['parameters'].user_parameters(is2d=False):
+            for p in model_info.parameters.user_parameters(is2d=False):
                 for ext in ['', '_pd', '_pd_n', '_pd_nsigma']:
                     k = p.name+ext
Index: sasmodels/convert.py
===================================================================
--- sasmodels/convert.py	(revision 8bd7b77da137d537b08a21d51f0a8000689d4cc6)
+++ sasmodels/convert.py	(revision 6d6508e66dc590f2ad678c70ef6fc78c3d4e684c)
@@ -153,10 +153,10 @@
 def revert_name(model_info):
     _read_conversion_table()
-    oldname, oldpars = CONVERSION_TABLE.get(model_info['id'], [None, {}])
+    oldname, oldpars = CONVERSION_TABLE.get(model_info.id, [None, {}])
     return oldname
 
 def _get_old_pars(model_info):
     _read_conversion_table()
-    oldname, oldpars = CONVERSION_TABLE.get(model_info['id'], [None, {}])
+    oldname, oldpars = CONVERSION_TABLE.get(model_info.id, [None, {}])
     return oldpars
 
@@ -165,6 +165,6 @@
     Convert model from new style parameter names to old style.
     """
-    if model_info['composition'] is not None:
-        composition_type, parts = model_info['composition']
+    if model_info.composition is not None:
+        composition_type, parts = model_info.composition
         if composition_type == 'product':
             P, S = parts
@@ -180,9 +180,9 @@
 
     # Note: update compare.constrain_pars to match
-    name = model_info['id']
-    if name in MODELS_WITHOUT_SCALE or model_info['structure_factor']:
+    name = model_info.id
+    if name in MODELS_WITHOUT_SCALE or model_info.structure_factor:
         if oldpars.pop('scale', 1.0) != 1.0:
             warnings.warn("parameter scale not used in sasview %s"%name)
-    if name in MODELS_WITHOUT_BACKGROUND or model_info['structure_factor']:
+    if name in MODELS_WITHOUT_BACKGROUND or model_info.structure_factor:
         if oldpars.pop('background', 0.0) != 0.0:
             warnings.warn("parameter background not used in sasview %s"%name)
@@ -225,9 +225,9 @@
     Restrict parameter values to those that will match sasview.
     """
-    name = model_info['id']
+    name = model_info.id
     # Note: update convert.revert_model to match
-    if name in MODELS_WITHOUT_SCALE or model_info['structure_factor']:
+    if name in MODELS_WITHOUT_SCALE or model_info.structure_factor:
         pars['scale'] = 1
-    if name in MODELS_WITHOUT_BACKGROUND or model_info['structure_factor']:
+    if name in MODELS_WITHOUT_BACKGROUND or model_info.structure_factor:
         pars['background'] = 0
     # sasview multiplies background by structure factor
Index: sasmodels/core.py
===================================================================
--- sasmodels/core.py	(revision ee8f73486ed89c512902019a13b040e576091f49)
+++ sasmodels/core.py	(revision 6d6508e66dc590f2ad678c70ef6fc78c3d4e684c)
@@ -2,20 +2,19 @@
 Core model handling routines.
 """
+from __future__ import print_function
+
 __all__ = [
-    "list_models", "load_model_info", "precompile_dll",
-    "build_model", "make_kernel", "call_kernel", "call_ER_VR",
+    "list_models", "load_model", "load_model_info",
+    "build_model", "precompile_dll",
     ]
 
-from os.path import basename, dirname, join as joinpath, splitext
+from os.path import basename, dirname, join as joinpath
 from glob import glob
 
 import numpy as np
 
-from . import models
-from . import weights
 from . import generate
-# TODO: remove circular references between product and core
-# product uses call_ER/call_VR, core uses make_product_info/ProductModel
-#from . import product
+from . import modelinfo
+from . import product
 from . import mixture
 from . import kernelpy
@@ -26,15 +25,4 @@
 except Exception:
     HAVE_OPENCL = False
-
-try:
-    np.meshgrid([])
-    meshgrid = np.meshgrid
-except ValueError:
-    # CRUFT: np.meshgrid requires multiple vectors
-    def meshgrid(*args):
-        if len(args) > 1:
-            return np.meshgrid(*args)
-        else:
-            return [np.asarray(v) for v in args]
 
 # TODO: refactor composite model support
@@ -88,6 +76,4 @@
     parts = model_name.split('*')
     if len(parts) > 1:
-        from . import product
-        # Note: currently have circular reference
         if len(parts) > 2:
             raise ValueError("use P*S to apply structure factor S to model P")
@@ -96,5 +82,5 @@
 
     kernel_module = generate.load_kernel_module(model_name)
-    return generate.make_model_info(kernel_module)
+    return modelinfo.make_model_info(kernel_module)
 
 
@@ -118,5 +104,5 @@
     otherwise it uses the default "ocl".
     """
-    composition = model_info.get('composition', None)
+    composition = model_info.composition
     if composition is not None:
         composition_type, parts = composition
@@ -137,10 +123,10 @@
     ##  4. rerun "python -m sasmodels.direct_model $MODELNAME"
     ##  5. uncomment open().read() so that source will be regenerated from model
-    # open(model_info['name']+'.c','w').write(source)
-    # source = open(model_info['name']+'.cl','r').read()
+    # open(model_info.name+'.c','w').write(source)
+    # source = open(model_info.name+'.cl','r').read()
     source = generate.make_source(model_info)
     if dtype is None:
-        dtype = 'single' if model_info['single'] else 'double'
-    if callable(model_info.get('Iq', None)):
+        dtype = 'single' if model_info.single else 'double'
+    if callable(model_info.Iq):
         return kernelpy.PyModel(model_info)
     if (platform == "dll"
@@ -168,139 +154,2 @@
     source = generate.make_source(model_info)
     return kerneldll.make_dll(source, model_info, dtype=dtype) if source else None
-
-
-def get_weights(parameter, values):
-    """
-    Generate the distribution for parameter *name* given the parameter values
-    in *pars*.
-
-    Uses "name", "name_pd", "name_pd_type", "name_pd_n", "name_pd_sigma"
-    from the *pars* dictionary for parameter value and parameter dispersion.
-    """
-    value = float(values.get(parameter.name, parameter.default))
-    relative = parameter.relative_pd
-    limits = parameter.limits
-    disperser = values.get(parameter.name+'_pd_type', 'gaussian')
-    npts = values.get(parameter.name+'_pd_n', 0)
-    width = values.get(parameter.name+'_pd', 0.0)
-    nsigma = values.get(parameter.name+'_pd_nsigma', 3.0)
-    if npts == 0 or width == 0:
-        return [value], []
-    value, weight = weights.get_weights(
-        disperser, npts, width, nsigma, value, limits, relative)
-    return value, weight / np.sum(weight)
-
-def dispersion_mesh(pars):
-    """
-    Create a mesh grid of dispersion parameters and weights.
-
-    Returns [p1,p2,...],w where pj is a vector of values for parameter j
-    and w is a vector containing the products for weights for each
-    parameter set in the vector.
-    """
-    value, weight = zip(*pars)
-    weight = [w if w else [1.] for w in weight]
-    value = [v.flatten() for v in meshgrid(*value)]
-    weight = np.vstack([v.flatten() for v in meshgrid(*weight)])
-    weight = np.prod(weight, axis=0)
-    return value, weight
-
-def call_kernel(kernel, pars, cutoff=0, mono=False):
-    """
-    Call *kernel* returned from *model.make_kernel* with parameters *pars*.
-
-    *cutoff* is the limiting value for the product of dispersion weights used
-    to perform the multidimensional dispersion calculation more quickly at a
-    slight cost to accuracy. The default value of *cutoff=0* integrates over
-    the entire dispersion cube.  Using *cutoff=1e-5* can be 50% faster, but
-    with an error of about 1%, which is usually less than the measurement
-    uncertainty.
-
-    *mono* is True if polydispersity should be set to none on all parameters.
-    """
-    parameters = kernel.info['parameters']
-    if mono:
-        active = lambda name: False
-    elif kernel.dim == '1d':
-        active = lambda name: name in parameters.pd_1d
-    elif kernel.dim == '2d':
-        active = lambda name: name in parameters.pd_2d
-    else:
-        active = lambda name: True
-
-    vw_pairs = [(get_weights(p, pars) if active(p.name)
-                 else ([pars.get(p.name, p.default)], []))
-                for p in parameters.call_parameters]
-
-    details, weights, values = build_details(kernel, vw_pairs)
-    return kernel(details, weights, values, cutoff)
-
-def build_details(kernel, pairs):
-    values, weights = zip(*pairs)
-    if max([len(w) for w in weights]) > 1:
-        details = generate.poly_details(kernel.info, weights)
-    else:
-        details = kernel.info['mono_details']
-    weights, values = [np.hstack(v) for v in (weights, values)]
-    weights = weights.astype(dtype=kernel.dtype)
-    values = values.astype(dtype=kernel.dtype)
-    return details, weights, values
-
-
-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(model_info, values):
-    """
-    Call the model ER function using *values*. *model_info* is either
-    *model.info* if you have a loaded model, or *kernel.info* if you
-    have a model kernel prepared for evaluation.
-    """
-    ER = model_info.get('ER', None)
-    if ER is None:
-        return 1.0
-    else:
-        vol_pars = [get_weights(parameter, values)
-                    for parameter in model_info['parameters'].call_parameters
-                    if parameter.type == 'volume']
-        value, weight = dispersion_mesh(vol_pars)
-        individual_radii = ER(*value)
-        return np.sum(weight*individual_radii) / np.sum(weight)
-
-def call_VR(model_info, values):
-    """
-    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.
-    """
-    VR = model_info.get('VR', None)
-    if VR is None:
-        return 1.0
-    else:
-        vol_pars = [get_weights(parameter, values)
-                    for parameter in model_info['parameters'].call_parameters
-                    if parameter.type == 'volume']
-        value, weight = dispersion_mesh(vol_pars)
-        whole, part = VR(*value)
-        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 ee8f73486ed89c512902019a13b040e576091f49)
+++ sasmodels/direct_model.py	(revision 6d6508e66dc590f2ad678c70ef6fc78c3d4e684c)
@@ -25,8 +25,61 @@
 import numpy as np
 
-from .core import call_kernel, call_ER_VR
 from . import sesans
+from . import weights
 from . import resolution
 from . import resolution2d
+from . import details
+
+
+def call_kernel(kernel, pars, cutoff=0, mono=False):
+    """
+    Call *kernel* returned from *model.make_kernel* with parameters *pars*.
+
+    *cutoff* is the limiting value for the product of dispersion weights used
+    to perform the multidimensional dispersion calculation more quickly at a
+    slight cost to accuracy. The default value of *cutoff=0* integrates over
+    the entire dispersion cube.  Using *cutoff=1e-5* can be 50% faster, but
+    with an error of about 1%, which is usually less than the measurement
+    uncertainty.
+
+    *mono* is True if polydispersity should be set to none on all parameters.
+    """
+    parameters = kernel.info.parameters
+    if mono:
+        active = lambda name: False
+    elif kernel.dim == '1d':
+        active = lambda name: name in parameters.pd_1d
+    elif kernel.dim == '2d':
+        active = lambda name: name in parameters.pd_2d
+    else:
+        active = lambda name: True
+
+    vw_pairs = [(get_weights(p, pars) if active(p.name)
+                 else ([pars.get(p.name, p.default)], []))
+                for p in parameters.call_parameters]
+
+    call_details, weights, values = details.build_details(kernel, vw_pairs)
+    return kernel(call_details, weights, values, cutoff)
+
+def get_weights(parameter, values):
+    """
+    Generate the distribution for parameter *name* given the parameter values
+    in *pars*.
+
+    Uses "name", "name_pd", "name_pd_type", "name_pd_n", "name_pd_sigma"
+    from the *pars* dictionary for parameter value and parameter dispersion.
+    """
+    value = float(values.get(parameter.name, parameter.default))
+    relative = parameter.relative_pd
+    limits = parameter.limits
+    disperser = values.get(parameter.name+'_pd_type', 'gaussian')
+    npts = values.get(parameter.name+'_pd_n', 0)
+    width = values.get(parameter.name+'_pd', 0.0)
+    nsigma = values.get(parameter.name+'_pd_nsigma', 3.0)
+    if npts == 0 or width == 0:
+        return [value], []
+    value, weight = weights.get_weights(
+        disperser, npts, width, nsigma, value, limits, relative)
+    return value, weight / np.sum(weight)
 
 class DataMixin(object):
@@ -80,5 +133,5 @@
             q_mono = sesans.make_all_q(data)
         elif self.data_type == 'Iqxy':
-            #if not model.info['parameters'].has_2d:
+            #if not model.info.parameters.has_2d:
             #    raise ValueError("not 2D without orientation or magnetic parameters")
             q = np.sqrt(data.qx_data**2 + data.qy_data**2)
@@ -211,10 +264,4 @@
     def __call__(self, **pars):
         return self._calc_theory(pars, cutoff=self.cutoff)
-
-    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):
Index: sasmodels/generate.py
===================================================================
--- sasmodels/generate.py	(revision 6e7ff6d026d856e1eb236a05975a2600ba9ba2c9)
+++ sasmodels/generate.py	(revision 6d6508e66dc590f2ad678c70ef6fc78c3d4e684c)
@@ -155,6 +155,5 @@
 #__all__ = ["model_info", "make_doc", "make_source", "convert_type"]
 
-from os.path import abspath, dirname, join as joinpath, exists, basename, \
-    splitext, getmtime
+from os.path import abspath, dirname, join as joinpath, exists, getmtime
 import re
 import string
@@ -163,5 +162,5 @@
 import numpy as np
 
-from .modelinfo import ModelInfo, Parameter, make_parameter_table, set_demo
+from .modelinfo import Parameter
 from .custom import load_custom_kernel_module
 
@@ -273,7 +272,7 @@
     Return a list of the sources file paths for the module.
     """
-    search_path = [dirname(model_info['filename']),
+    search_path = [dirname(model_info.filename),
                    abspath(joinpath(dirname(__file__), 'models'))]
-    return [_search(search_path, f) for f in model_info['source']]
+    return [_search(search_path, f) for f in model_info.source]
 
 def timestamp(model_info):
@@ -284,5 +283,5 @@
     source_files = (model_sources(model_info)
                     + model_templates()
-                    + [model_info['filename']])
+                    + [model_info.filename])
     newest = max(getmtime(f) for f in source_files)
     return newest
@@ -334,5 +333,5 @@
     Name of the exported kernel symbol.
     """
-    return model_info['name'] + "_" + ("Iqxy" if is_2d else "Iq")
+    return model_info.name + "_" + ("Iqxy" if is_2d else "Iq")
 
 
@@ -420,5 +419,5 @@
     Uses source files found in the given search path.
     """
-    if callable(model_info['Iq']):
+    if callable(model_info.Iq):
         return None
 
@@ -432,5 +431,5 @@
     # for computing volume even if we allow non-disperse volume parameters.
 
-    partable = model_info['parameters']
+    partable = model_info.parameters
 
     # Identify parameters for Iq, Iqxy, Iq_magnetic and form_volume.
@@ -448,13 +447,13 @@
     q, qx, qy = [Parameter(name=v) for v in ('q', 'qx', 'qy')]
     # Generate form_volume function, etc. from body only
-    if model_info['form_volume'] is not None:
+    if model_info.form_volume is not None:
         pars = partable.form_volume_parameters
-        source.append(_gen_fn('form_volume', pars, model_info['form_volume']))
-    if model_info['Iq'] is not None:
+        source.append(_gen_fn('form_volume', pars, model_info.form_volume))
+    if model_info.Iq is not None:
         pars = [q] + partable.iq_parameters
-        source.append(_gen_fn('Iq', pars, model_info['Iq']))
-    if model_info['Iqxy'] is not None:
+        source.append(_gen_fn('Iq', pars, model_info.Iq))
+    if model_info.Iqxy is not None:
         pars = [qx, qy] + partable.iqxy_parameters
-        source.append(_gen_fn('Iqxy', pars, model_info['Iqxy']))
+        source.append(_gen_fn('Iqxy', pars, model_info.Iqxy))
 
     # Define the parameter table
@@ -494,5 +493,5 @@
 
     # define the Iq kernel
-    source.append("#define KERNEL_NAME %s_Iq"%model_info['name'])
+    source.append("#define KERNEL_NAME %s_Iq"%model_info.name)
     source.append(call_iq)
     source.append(kernel_code)
@@ -501,5 +500,5 @@
 
     # define the Iqxy kernel from the same source with different #defines
-    source.append("#define KERNEL_NAME %s_Iqxy"%model_info['name'])
+    source.append("#define KERNEL_NAME %s_Iqxy"%model_info.name)
     source.append(call_iqxy)
     source.append(kernel_code)
@@ -508,170 +507,4 @@
 
     return '\n'.join(source)
-
-class CoordinationDetails(object):
-    def __init__(self, model_info):
-        parameters = model_info['parameters']
-        max_pd = parameters.max_pd
-        npars = parameters.npars
-        par_offset = 4*max_pd
-        self.details = np.zeros(par_offset + 3*npars + 4, 'i4')
-
-        # generate views on different parts of the array
-        self._pd_par     = self.details[0*max_pd:1*max_pd]
-        self._pd_length  = self.details[1*max_pd:2*max_pd]
-        self._pd_offset  = self.details[2*max_pd:3*max_pd]
-        self._pd_stride  = self.details[3*max_pd:4*max_pd]
-        self._par_offset = self.details[par_offset+0*npars:par_offset+1*npars]
-        self._par_coord  = self.details[par_offset+1*npars:par_offset+2*npars]
-        self._pd_coord   = self.details[par_offset+2*npars:par_offset+3*npars]
-
-        # theta_par is fixed
-        self.details[-1] = parameters.theta_offset
-
-    @property
-    def ctypes(self): return self.details.ctypes
-
-    @property
-    def pd_par(self): return self._pd_par
-
-    @property
-    def pd_length(self): return self._pd_length
-
-    @property
-    def pd_offset(self): return self._pd_offset
-
-    @property
-    def pd_stride(self): return self._pd_stride
-
-    @property
-    def pd_coord(self): return self._pd_coord
-
-    @property
-    def par_coord(self): return self._par_coord
-
-    @property
-    def par_offset(self): return self._par_offset
-
-    @property
-    def num_active(self): return self.details[-4]
-    @num_active.setter
-    def num_active(self, v): self.details[-4] = v
-
-    @property
-    def total_pd(self): return self.details[-3]
-    @total_pd.setter
-    def total_pd(self, v): self.details[-3] = v
-
-    @property
-    def num_coord(self): return self.details[-2]
-    @num_coord.setter
-    def num_coord(self, v): self.details[-2] = v
-
-    @property
-    def theta_par(self): return self.details[-1]
-
-    def show(self):
-        print("total_pd", self.total_pd)
-        print("num_active", self.num_active)
-        print("pd_par", self.pd_par)
-        print("pd_length", self.pd_length)
-        print("pd_offset", self.pd_offset)
-        print("pd_stride", self.pd_stride)
-        print("par_offsets", self.par_offset)
-        print("num_coord", self.num_coord)
-        print("par_coord", self.par_coord)
-        print("pd_coord", self.pd_coord)
-        print("theta par", self.details[-1])
-
-def mono_details(model_info):
-    details = CoordinationDetails(model_info)
-    # The zero defaults for monodisperse systems are mostly fine
-    details.par_offset[:] = np.arange(2, len(details.par_offset)+2)
-    return details
-
-def poly_details(model_info, weights):
-    #print("weights",weights)
-    weights = weights[2:] # Skip scale and background
-
-    # Decreasing list of polydispersity lengths
-    # Note: the reversing view, x[::-1], does not require a copy
-    pd_length = np.array([len(w) for w in weights])
-    num_active = np.sum(pd_length>1)
-    if num_active > model_info['parameters'].max_pd:
-        raise ValueError("Too many polydisperse parameters")
-
-    pd_offset = np.cumsum(np.hstack((0, pd_length)))
-    idx = np.argsort(pd_length)[::-1][:num_active]
-    par_length = np.array([max(len(w),1) for w in weights])
-    pd_stride = np.cumprod(np.hstack((1, par_length[idx])))
-    par_offsets = np.cumsum(np.hstack((2, par_length)))
-
-    details = CoordinationDetails(model_info)
-    details.pd_par[:num_active] = idx
-    details.pd_length[:num_active] = pd_length[idx]
-    details.pd_offset[:num_active] = pd_offset[idx]
-    details.pd_stride[:num_active] = pd_stride[:-1]
-    details.par_offset[:] = par_offsets[:-1]
-    details.total_pd = pd_stride[-1]
-    details.num_active = num_active
-    # Without constraints coordinated parameters are just the pd parameters
-    details.par_coord[:num_active] = idx
-    details.pd_coord[:num_active] = 2**np.arange(num_active)
-    details.num_coord = num_active
-    #details.show()
-    return details
-
-def constrained_poly_details(model_info, weights, constraints):
-    # Need to find the independently varying pars and sort them
-    # Need to build a coordination list for the dependent variables
-    # Need to generate a constraints function which takes values
-    # and weights, returning par blocks
-    raise NotImplementedError("Can't handle constraints yet")
-
-
-def create_vector_Iq(model_info):
-    """
-    Define Iq as a vector function if it exists.
-    """
-    Iq = model_info['Iq']
-    if callable(Iq) and not getattr(Iq, 'vectorized', False):
-        def vector_Iq(q, *args):
-            """
-            Vectorized 1D kernel.
-            """
-            return np.array([Iq(qi, *args) for qi in q])
-        model_info['Iq'] = vector_Iq
-
-def create_vector_Iqxy(model_info):
-    """
-    Define Iqxy as a vector function if it exists, or default it from Iq().
-    """
-    Iq, Iqxy = model_info['Iq'], model_info['Iqxy']
-    if callable(Iqxy) and not getattr(Iqxy, 'vectorized', False):
-        def vector_Iqxy(qx, qy, *args):
-            """
-            Vectorized 2D kernel.
-            """
-            return np.array([Iqxy(qxi, qyi, *args) for qxi, qyi in zip(qx, qy)])
-        model_info['Iqxy'] = vector_Iqxy
-    elif callable(Iq):
-        # Iq is vectorized because create_vector_Iq was already called.
-        def default_Iqxy(qx, qy, *args):
-            """
-            Default 2D kernel.
-            """
-            return Iq(np.sqrt(qx**2 + qy**2), *args)
-        model_info['Iqxy'] = default_Iqxy
-
-def create_default_functions(model_info):
-    """
-    Autogenerate missing functions, such as Iqxy from Iq.
-
-    This only works for Iqxy when Iq is written in python. :func:`make_source`
-    performs a similar role for Iq written in C.  This also vectorizes
-    any functions that are not already marked as vectorized.
-    """
-    create_vector_Iq(model_info)
-    create_vector_Iqxy(model_info)  # call create_vector_Iq() first
 
 def load_kernel_module(model_name):
@@ -685,80 +518,4 @@
 
 
-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
-    * *filename* is the full path to the module defining the file (if any)
-    * *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
-    * *structure_factor* is True if the model is useable in a product
-    * *variant_info* contains the information required to select between
-      model variants (e.g., the list of cases) or is None if there are no
-      model variants
-    * *par_type* 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*, *VR* and *sesans* are python functions
-      implementing the kernel for the module, or None if they are not
-      defined in python
-    * *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("make parameter table", kernel_module.parameters)
-    parameters = make_parameter_table(kernel_module.parameters)
-    filename = abspath(kernel_module.__file__)
-    kernel_id = splitext(basename(filename))[0]
-    name = getattr(kernel_module, 'name', None)
-    if name is None:
-        name = " ".join(w.capitalize() for w in kernel_id.split('_'))
-    model_info = dict(
-        id=kernel_id,  # string used to load the kernel
-        filename=abspath(kernel_module.__file__),
-        name=name,
-        title=getattr(kernel_module, 'title', name+" model"),
-        description=getattr(kernel_module, 'description', 'no description'),
-        parameters=parameters,
-        composition=None,
-        docs=kernel_module.__doc__,
-        category=getattr(kernel_module, 'category', None),
-        single=getattr(kernel_module, 'single', True),
-        structure_factor=getattr(kernel_module, 'structure_factor', False),
-        profile_axes=getattr(kernel_module, 'profile_axes', ['x','y']),
-        variant_info=getattr(kernel_module, 'invariant_info', None),
-        demo=getattr(kernel_module, 'demo', None),
-        source=getattr(kernel_module, 'source', []),
-        tests=getattr(kernel_module, 'tests', []),
-        )
-    set_demo(model_info, getattr(kernel_module, 'demo', None))
-    # Check for optional functions
-    functions = "ER VR form_volume Iq Iqxy profile sesans".split()
-    model_info.update((k, getattr(kernel_module, k, None)) for k in functions)
-    create_default_functions(model_info)
-    # Precalculate the monodisperse parameters
-    # TODO: make this a lazy evaluator
-    # make_model_info is called for every model on sasview startup
-    model_info['mono_details'] = mono_details(model_info)
-    return model_info
 
 section_marker = re.compile(r'\A(?P<first>[%s])(?P=first)*\Z'
@@ -801,10 +558,10 @@
     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)$."
-    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 model_info['structure_factor'] else Iq_units,
+    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 model_info.structure_factor else Iq_units,
                  docs=docs)
     return DOC_HEADER % subst
@@ -815,6 +572,8 @@
     Show how long it takes to process a model.
     """
+    import datetime
+    from .modelinfo import make_model_info
     from .models import cylinder
-    import datetime
+
     tic = datetime.datetime.now()
     make_source(make_model_info(cylinder))
@@ -827,4 +586,6 @@
     """
     import sys
+    from .modelinfo import make_model_info
+
     if len(sys.argv) <= 1:
         print("usage: python -m sasmodels.generate modelname")
Index: sasmodels/kernelcl.py
===================================================================
--- sasmodels/kernelcl.py	(revision d2bb6049fb1ca2c1d60bcd3853b1d2ae889866b2)
+++ sasmodels/kernelcl.py	(revision 6d6508e66dc590f2ad678c70ef6fc78c3d4e684c)
@@ -342,5 +342,5 @@
         if self.program is None:
             compiler = environment().compile_program
-            self.program = compiler(self.info['name'], self.source,
+            self.program = compiler(self.info.name, self.source,
                                     self.dtype, self.fast)
         is_2d = len(q_vectors) == 2
@@ -354,5 +354,5 @@
         """
         if self.program is not None:
-            environment().release_program(self.info['name'])
+            environment().release_program(self.info.name)
             self.program = None
 
@@ -441,6 +441,6 @@
     """
     def __init__(self, kernel, model_info, q_vectors, dtype):
-        max_pd = model_info['max_pd']
-        npars = len(model_info['parameters'])-2
+        max_pd = model_info.max_pd
+        npars = len(model_info.parameters)-2
         q_input = GpuInput(q_vectors, dtype)
         self.dtype = dtype
@@ -465,15 +465,15 @@
         self._need_release = [ self.result_b, self.q_input ]
 
-    def __call__(self, details, weights, values, cutoff):
+    def __call__(self, call_details, weights, values, cutoff):
         real = (np.float32 if self.q_input.dtype == generate.F32
                 else np.float64 if self.q_input.dtype == generate.F64
                 else np.float16 if self.q_input.dtype == generate.F16
                 else np.float32)  # will never get here, so use np.float32
-        assert details.dtype == np.int32
+        assert call_details.dtype == np.int32
         assert weights.dtype == real and values.dtype == real
 
         context = self.queue.context
         details_b = cl.Buffer(context, mf.READ_ONLY | mf.COPY_HOST_PTR,
-                              hostbuf=details)
+                              hostbuf=call_details)
         weights_b = cl.Buffer(context, mf.READ_ONLY | mf.COPY_HOST_PTR,
                               hostbuf=weights)
Index: sasmodels/kerneldll.py
===================================================================
--- sasmodels/kerneldll.py	(revision 4d767110d389aa65268b4b929ac8df1413460380)
+++ sasmodels/kerneldll.py	(revision 6d6508e66dc590f2ad678c70ef6fc78c3d4e684c)
@@ -55,4 +55,5 @@
 
 from . import generate
+from . import details
 from .kernelpy import PyInput, PyModel
 from .exception import annotate_exception
@@ -95,5 +96,5 @@
     """
     bits = 8*dtype.itemsize
-    return "sas_%s%d"%(model_info['id'], bits)
+    return "sas_%s%d"%(model_info.id, bits)
 
 def dll_path(model_info, dtype):
@@ -123,5 +124,5 @@
     models are allowed as DLLs.
     """
-    if callable(model_info.get('Iq', None)):
+    if callable(model_info.Iq):
         return PyModel(model_info)
     
@@ -259,12 +260,12 @@
         self.result = np.empty(q_input.nq+3, q_input.dtype)
 
-    def __call__(self, details, weights, values, cutoff):
+    def __call__(self, call_details, weights, values, cutoff):
         real = (np.float32 if self.q_input.dtype == generate.F32
                 else np.float64 if self.q_input.dtype == generate.F64
                 else np.float128)
-        assert isinstance(details, generate.CoordinationDetails)
+        assert isinstance(call_details, details.CallDetails)
         assert weights.dtype == real and values.dtype == real
 
-        start, stop = 0, details.total_pd
+        start, stop = 0, call_details.total_pd
         #print("in kerneldll")
         #print("weights", weights)
@@ -274,5 +275,5 @@
             start, # pd_start
             stop, # pd_stop pd_stride[MAX_PD]
-            details.ctypes.data, # problem
+            call_details.ctypes.data, # problem
             weights.ctypes.data,  # weights
             values.ctypes.data,  #pars
Index: sasmodels/kernelpy.py
===================================================================
--- sasmodels/kernelpy.py	(revision a8a7f08257f4c8c4b1cd0c507c2f6a341c7050a4)
+++ sasmodels/kernelpy.py	(revision 6d6508e66dc590f2ad678c70ef6fc78c3d4e684c)
@@ -17,9 +17,11 @@
     """
     def __init__(self, model_info):
+        # Make sure Iq and Iqxy are available and vectorized
+        _create_default_functions(model_info)
         self.info = model_info
 
     def make_kernel(self, q_vectors):
         q_input = PyInput(q_vectors, dtype=F64)
-        kernel = self.info['Iqxy'] if q_input.is_2d else self.info['Iq']
+        kernel = self.info.Iqxy if q_input.is_2d else self.info.Iq
         return PyKernel(kernel, self.info, q_input)
 
@@ -94,5 +96,5 @@
         self.dim = '2d' if q_input.is_2d else '1d'
 
-        partable = model_info['parameters']
+        partable = model_info.parameters
         kernel_parameters = (partable.iqxy_parameters if q_input.is_2d
                              else partable.iq_parameters)
@@ -129,21 +131,21 @@
         # parameter array.
         if q_input.is_2d:
-            form = model_info['Iqxy']
+            form = model_info.Iqxy
             qx, qy = q_input.q[:,0], q_input.q[:,1]
             self._form = lambda: form(qx, qy, *kernel_args)
         else:
-            form = model_info['Iq']
+            form = model_info.Iq
             q = q_input.q
             self._form = lambda: form(q, *kernel_args)
 
         # Generate a closure which calls the form_volume if it exists.
-        form_volume = model_info['form_volume']
+        form_volume = model_info.form_volume
         self._volume = ((lambda: form_volume(*volume_args)) if form_volume
                         else (lambda: 1.0))
 
-    def __call__(self, details, weights, values, cutoff):
-        # type: (.generate.CoordinationDetails, np.ndarray, np.ndarray, float) -> np.ndarray
+    def __call__(self, call_details, weights, values, cutoff):
+        # type: (.generate.CallDetails, np.ndarray, np.ndarray, float) -> np.ndarray
         res = _loops(self._parameter_vector, self._form, self._volume,
-                     self.q_input.nq, details, weights, values, cutoff)
+                     self.q_input.nq, call_details, weights, values, cutoff)
         return res
 
@@ -158,5 +160,5 @@
            form_volume, # type: Callable[[], float]
            nq,          # type: int
-           details,     # type: .generate.CoordinationDetails
+           call_details,# type: .generate.CallDetails
            weights,     # type: np.ndarray
            values,      # type: np.ndarray
@@ -172,7 +174,7 @@
     #                                                              #
     ################################################################
-    parameters[:] = values[details.par_offset]
+    parameters[:] = values[call_details.par_offset]
     scale, background = values[0], values[1]
-    if details.num_active == 0:
+    if call_details.num_active == 0:
         norm = float(form_volume())
         if norm > 0.0:
@@ -183,23 +185,23 @@
     partial_weight = np.NaN
     spherical_correction = 1.0
-    pd_stride = details.pd_stride[:details.num_active]
-    pd_length = details.pd_length[:details.num_active]
-    pd_offset = details.pd_offset[:details.num_active]
+    pd_stride = call_details.pd_stride[:call_details.num_active]
+    pd_length = call_details.pd_length[:call_details.num_active]
+    pd_offset = call_details.pd_offset[:call_details.num_active]
     pd_index = np.empty_like(pd_offset)
-    offset = np.empty_like(details.par_offset)
-    theta = details.theta_par
+    offset = np.empty_like(call_details.par_offset)
+    theta = call_details.theta_par
     fast_length = pd_length[0]
     pd_index[0] = fast_length
     total = np.zeros(nq, 'd')
     norm = 0.0
-    for loop_index in range(details.total_pd):
+    for loop_index in range(call_details.total_pd):
         # update polydispersity parameter values
         if pd_index[0] == fast_length:
             pd_index[:] = (loop_index/pd_stride)%pd_length
             partial_weight = np.prod(weights[pd_offset+pd_index][1:])
-            for k in range(details.num_coord):
-                par = details.par_coord[k]
-                coord = details.pd_coord[k]
-                this_offset = details.par_offset[par]
+            for k in range(call_details.num_coord):
+                par = call_details.par_coord[k]
+                coord = call_details.pd_coord[k]
+                this_offset = call_details.par_offset[par]
                 block_size = 1
                 for bit in xrange(32):
@@ -211,9 +213,9 @@
                 offset[par] = this_offset
                 parameters[par] = values[this_offset]
-                if par == theta and not (details.par_coord[k]&1):
+                if par == theta and not (call_details.par_coord[k]&1):
                     spherical_correction = max(abs(cos(pi/180 * parameters[theta])), 1e-6)
-        for k in range(details.num_coord):
-            if details.pd_coord[k]&1:
-                #par = details.par_coord[k]
+        for k in range(call_details.num_coord):
+            if call_details.pd_coord[k]&1:
+                #par = call_details.par_coord[k]
                 parameters[par] = values[offset[par]]
                 #print "par",par,offset[par],parameters[par+2]
@@ -241,2 +243,53 @@
     else:
         return np.ones(nq, 'd')*background
+
+
+
+def _create_vector_Iq(model_info):
+    """
+    Define Iq as a vector function if it exists.
+    """
+    Iq = model_info.Iq
+    if callable(Iq) and not getattr(Iq, 'vectorized', False):
+        def vector_Iq(q, *args):
+            """
+            Vectorized 1D kernel.
+            """
+            return np.array([Iq(qi, *args) for qi in q])
+        vector_Iq.vectorized = True
+        model_info.Iq = vector_Iq
+
+def _create_vector_Iqxy(model_info):
+    """
+    Define Iqxy as a vector function if it exists, or default it from Iq().
+    """
+    Iq, Iqxy = model_info.Iq, model_info.Iqxy
+    if callable(Iqxy) and not getattr(Iqxy, 'vectorized', False):
+        def vector_Iqxy(qx, qy, *args):
+            """
+            Vectorized 2D kernel.
+            """
+            return np.array([Iqxy(qxi, qyi, *args) for qxi, qyi in zip(qx, qy)])
+        vector_Iqxy.vectorized = True
+        model_info.Iqxy = vector_Iqxy
+    elif callable(Iq):
+        # Iq is vectorized because create_vector_Iq was already called.
+        def default_Iqxy(qx, qy, *args):
+            """
+            Default 2D kernel.
+            """
+            return Iq(np.sqrt(qx**2 + qy**2), *args)
+        default_Iqxy.vectorized = True
+        model_info.Iqxy = default_Iqxy
+
+def _create_default_functions(model_info):
+    """
+    Autogenerate missing functions, such as Iqxy from Iq.
+
+    This only works for Iqxy when Iq is written in python. :func:`make_source`
+    performs a similar role for Iq written in C.  This also vectorizes
+    any functions that are not already marked as vectorized.
+    """
+    _create_vector_Iq(model_info)
+    _create_vector_Iqxy(model_info)  # call create_vector_Iq() first
+
Index: sasmodels/list_pars.py
===================================================================
--- sasmodels/list_pars.py	(revision 21b116f73d822594145622e342e0308fc3ecd14a)
+++ sasmodels/list_pars.py	(revision 6d6508e66dc590f2ad678c70ef6fc78c3d4e684c)
@@ -25,5 +25,5 @@
     for name in sorted(MODELS):
         model_info = load_model_info(name)
-        for p in model_info['parameters'].kernel_parameters:
+        for p in model_info.parameters.kernel_parameters:
             partable.setdefault(p.name, [])
             partable[p.name].append(name)
Index: sasmodels/mixture.py
===================================================================
--- sasmodels/mixture.py	(revision 69aa451cd7078d77c255be72d487793b00b34eee)
+++ sasmodels/mixture.py	(revision 6d6508e66dc590f2ad678c70ef6fc78c3d4e684c)
@@ -14,10 +14,5 @@
 import numpy as np
 
-from .modelinfo import Parameter, ParameterTable
-
-SCALE=0
-BACKGROUND=1
-EFFECT_RADIUS=2
-VOLFRACTION=3
+from .modelinfo import Parameter, ParameterTable, ModelInfo
 
 def make_mixture_info(parts):
@@ -50,21 +45,21 @@
     partable = ParameterTable(pars)
 
-    model_info = {}
-    model_info['id'] = '+'.join(part['id'])
-    model_info['name'] = ' + '.join(part['name'])
-    model_info['filename'] = None
-    model_info['title'] = 'Mixture model with ' + model_info['name']
-    model_info['description'] = model_info['title']
-    model_info['docs'] = model_info['title']
-    model_info['category'] = "custom"
-    model_info['parameters'] = partable
-    #model_info['single'] = any(part['single'] for part in parts)
-    model_info['structure_factor'] = False
-    model_info['variant_info'] = None
-    #model_info['tests'] = []
-    #model_info['source'] = []
+    model_info = ModelInfo()
+    model_info.id = '+'.join(part['id'])
+    model_info.name = ' + '.join(part['name'])
+    model_info.filename = None
+    model_info.title = 'Mixture model with ' + model_info.name
+    model_info.description = model_info.title
+    model_info.docs = model_info.title
+    model_info.category = "custom"
+    model_info.parameters = partable
+    #model_info.single = any(part['single'] for part in parts)
+    model_info.structure_factor = False
+    model_info.variant_info = None
+    #model_info.tests = []
+    #model_info.source = []
     # Iq, Iqxy, form_volume, ER, VR and sesans
     # Remember the component info blocks so we can build the model
-    model_info['composition'] = ('mixture', parts)
+    model_info.composition = ('mixture', parts)
 
 
@@ -108,9 +103,9 @@
         if dim == '2d':
             for p in kernels:
-                partype = p.info['partype']
+                partype = p.info.partype
                 accumulate(partype['fixed-2d'], partype['pd-2d'], partype['volume'])
         else:
             for p in kernels:
-                partype = p.info['partype']
+                partype = p.info.partype
                 accumulate(partype['fixed-1d'], partype['pd-1d'], partype['volume'])
 
Index: sasmodels/model_test.py
===================================================================
--- sasmodels/model_test.py	(revision 6e7ff6d026d856e1eb236a05975a2600ba9ba2c9)
+++ sasmodels/model_test.py	(revision 6d6508e66dc590f2ad678c70ef6fc78c3d4e684c)
@@ -43,4 +43,6 @@
 Precision defaults to 5 digits (relative).
 """
+#TODO: rename to tests so that tab completion works better for models directory
+
 from __future__ import print_function
 
@@ -51,8 +53,41 @@
 
 from .core import list_models, load_model_info, build_model, HAVE_OPENCL
-from .core import call_kernel, call_ER, call_VR
+from .details import dispersion_mesh
+from .direct_model import call_kernel, get_weights
 from .exception import annotate_exception
 
-#TODO: rename to tests so that tab completion works better for models directory
+
+def call_ER(model_info, values):
+    """
+    Call the model ER function using *values*. *model_info* is either
+    *model.info* if you have a loaded model, or *kernel.info* if you
+    have a model kernel prepared for evaluation.
+    """
+    if model_info.ER is None:
+        return 1.0
+    else:
+        vol_pars = [get_weights(parameter, values)
+                    for parameter in model_info.parameters.call_parameters
+                    if parameter.type == 'volume']
+        value, weight = dispersion_mesh(vol_pars)
+        individual_radii = model_info.ER(*value)
+        return np.sum(weight*individual_radii) / np.sum(weight)
+
+def call_VR(model_info, values):
+    """
+    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.
+    """
+    if model_info.VR is None:
+        return 1.0
+    else:
+        vol_pars = [get_weights(parameter, values)
+                    for parameter in model_info.parameters.call_parameters
+                    if parameter.type == 'volume']
+        value, weight = dispersion_mesh(vol_pars)
+        whole, part = model_info.VR(*value)
+        return np.sum(weight*part)/np.sum(weight*whole)
+
 
 def make_suite(loaders, models):
@@ -86,5 +121,5 @@
         # don't try to call cl kernel since it will not be
         # available in some environmentes.
-        is_py = callable(model_info['Iq'])
+        is_py = callable(model_info.Iq)
 
         if is_py:  # kernel implemented in python
@@ -151,5 +186,5 @@
                 ]
 
-            tests = self.info['tests']
+            tests = self.info.tests
             try:
                 model = build_model(self.info, dtype=self.dtype,
Index: sasmodels/modelinfo.py
===================================================================
--- sasmodels/modelinfo.py	(revision 6e7ff6d026d856e1eb236a05975a2600ba9ba2c9)
+++ sasmodels/modelinfo.py	(revision 6d6508e66dc590f2ad678c70ef6fc78c3d4e684c)
@@ -1,7 +1,8 @@
+from copy import copy
+from os.path import abspath, basename, splitext
 
 import numpy as np
 
-# TODO: turn ModelInfo into a proper class
-ModelInfo = dict
+from .details import mono_details
 
 MAX_PD = 4
@@ -116,18 +117,16 @@
 
 
-def set_demo(model_info, demo):
-    """
-    Assign demo parameters to model_info['demo']
-
-    If demo is not defined, then use defaults.
-
-    If demo is defined, override defaults with value from demo.
-
-    If demo references vector fields, such as thickness[n], then support
+def make_demo_pars(partable, demo):
+    """
+    Create demo parameter set from key-value pairs.
+
+    *demo* are the key-value pairs to use for the demo parameters.  Any
+    parameters not specified in *demo* are set from the *partable* defaults.
+
+    If *demo* references vector fields, such as thickness[n], then support
     different ways of assigning the demo values, including assigning a
     specific value (e.g., thickness3=50.0), assigning a new value to all
     (e.g., thickness=50.0) or assigning values using list notation.
     """
-    partable = model_info['parameters']
     if demo is None:
         result = partable.defaults
@@ -157,5 +156,22 @@
             result.update(demo)
 
-    model_info['demo'] = result
+    return result
+
+def prefix_parameter(par, prefix):
+    """
+    Return a copy of the parameter with its name prefixed.
+    """
+    new_par = copy(par)
+    new_par.name = prefix + par.name
+    new_par.id = prefix + par.id
+
+def suffix_parameter(par, suffix):
+    """
+    Return a copy of the parameter with its name prefixed.
+    """
+    new_par = copy(par)
+    # If name has the form x[n], replace with x_suffix[n]
+    new_par.name = par.id + suffix + par.name[len(par.id):]
+    new_par.id = par.id + suffix
 
 class Parameter(object):
@@ -517,2 +533,112 @@
     return isinstance(x, str)
 
+def make_model_info(kernel_module):
+    info = ModelInfo()
+    #print("make parameter table", kernel_module.parameters)
+    parameters = make_parameter_table(kernel_module.parameters)
+    demo = make_demo_pars(parameters, getattr(kernel_module, 'demo', None))
+    filename = abspath(kernel_module.__file__)
+    kernel_id = splitext(basename(filename))[0]
+    name = getattr(kernel_module, 'name', None)
+    if name is None:
+        name = " ".join(w.capitalize() for w in kernel_id.split('_'))
+
+    info.id = kernel_id  # string used to load the kernel
+    info.filename = abspath(kernel_module.__file__)
+    info.name = name
+    info.title = getattr(kernel_module, 'title', name+" model")
+    info.description = getattr(kernel_module, 'description', 'no description')
+    info.parameters = parameters
+    info.demo = demo
+    info.composition = None
+    info.docs = kernel_module.__doc__
+    info.category = getattr(kernel_module, 'category', None)
+    info.single = getattr(kernel_module, 'single', True)
+    info.structure_factor = getattr(kernel_module, 'structure_factor', False)
+    info.profile_axes = getattr(kernel_module, 'profile_axes', ['x','y'])
+    info.variant_info = getattr(kernel_module, 'invariant_info', None)
+    info.demo = getattr(kernel_module, 'demo', None)
+    info.source = getattr(kernel_module, 'source', [])
+    info.tests = getattr(kernel_module, 'tests', [])
+    info.ER = getattr(kernel_module, 'ER', None)
+    info.VR = getattr(kernel_module, 'VR', None)
+    info.form_volume = getattr(kernel_module, 'form_volume', None)
+    info.Iq = getattr(kernel_module, 'Iq', None)
+    info.Iqxy = getattr(kernel_module, 'Iqxy', None)
+    info.profile = getattr(kernel_module, 'profile', None)
+    info.sesans = getattr(kernel_module, 'sesans', None)
+
+    # Precalculate the monodisperse parameter details
+    info.mono_details = mono_details(info)
+    return info
+
+class ModelInfo(object):
+    """
+    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
+    * *filename* is the full path to the module defining the file (if any)
+    * *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
+    * *structure_factor* is True if the model is useable in a product
+    * *variant_info* contains the information required to select between
+      model variants (e.g., the list of cases) or is None if there are no
+      model variants
+    * *par_type* 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*, *VR* and *sesans* are python functions
+      implementing the kernel for the module, or None if they are not
+      defined in python
+    * *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.
+    """
+    id = None
+    filename = None
+    name = None
+    title = None
+    description = None
+    parameters = None
+    demo = None
+    composition = None
+    docs = None
+    category = None
+    single = None
+    structure_factor = None
+    profile_axes = None
+    variant_info = None
+    demo = None
+    source = None
+    tests = None
+    ER = None
+    VR = None
+    form_volume = None
+    Iq = None
+    Iqxy = None
+    profile = None
+    sesans = None
+    mono_details = None
+
+    def __init__(self):
+        pass
+
+
Index: sasmodels/product.py
===================================================================
--- sasmodels/product.py	(revision f2473145a6baa12aa3ab15421a80d2f5d004935c)
+++ sasmodels/product.py	(revision 6d6508e66dc590f2ad678c70ef6fc78c3d4e684c)
@@ -13,10 +13,12 @@
 import numpy as np
 
-from .core import call_ER_VR
+from .details import dispersion_mesh
+from .modelinfo import suffix_parameter, ParameterTable, Parameter, ModelInfo
 
-SCALE=0
-BACKGROUND=1
-RADIUS_EFFECTIVE=2
-VOLFRACTION=3
+# TODO: make estimates available to constraints
+#ESTIMATED_PARAMETERS = [
+#    ["est_effect_radius", "A", 0.0, [0, np.inf], "", "Estimated effective radius"],
+#    ["est_volume_ratio", "", 1.0, [0, np.inf], "", "Estimated volume ratio"],
+#]
 
 # TODO: core_shell_sphere model has suppressed the volume ratio calculation
@@ -26,35 +28,34 @@
     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].name == 'scale'
-    assert s_pars[BACKGROUND].name == 'background'
-    # We require structure factors to start with effect radius and volfraction
-    assert s_pars[RADIUS_EFFECTIVE].name == 'radius_effective'
-    assert s_pars[VOLFRACTION].name == '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.name for p in pars)) != len(pars):
-        raise ValueError("Duplicate parameters in %s and %s"%(p_id))
-    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['docs'] = model_info['title']
-    model_info['category'] = "custom"
-    model_info['parameters'] = pars
-    #model_info['single'] = p_info['single'] and s_info['single']
-    model_info['structure_factor'] = False
-    model_info['variant_info'] = None
-    #model_info['tests'] = []
-    #model_info['source'] = []
+    p_id, p_name, p_partable = p_info.id, p_info.name, p_info.parameters
+    s_id, s_name, s_partable = s_info.id, s_info.name, s_info.parameters
+    p_set = set(p.id for p in p_partable)
+    s_set = set(p.id for p in s_partable)
+
+    if p_set & s_set:
+        # there is some overlap between the parameter names; tag the
+        # overlapping S parameters with name_S
+        s_pars = [(suffix_parameter(par, "_S") if par.id in p_set else par)
+                  for par in s_partable.kernel_parameters]
+        pars = p_partable.kernel_parameters + s_pars
+    else:
+        pars= p_partable.kernel_parameters + s_partable.kernel_parameters
+
+    model_info = ModelInfo()
+    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 %s'%(p_name, s_name)
+    model_info.description = model_info.title
+    model_info.docs = model_info.title
+    model_info.category = "custom"
+    model_info.parameters = ParameterTable(pars)
+    #model_info.single = p_info.single and s_info.single
+    model_info.structure_factor = False
+    model_info.variant_info = None
+    #model_info.tests = []
+    #model_info.source = []
     # Iq, Iqxy, form_volume, ER, VR and sesans
-    model_info['composition'] = ('product', [p_info, s_info])
+    model_info.composition = ('product', [p_info, s_info])
     return model_info
 
@@ -86,64 +87,9 @@
 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['par_type']
-        s_info = s_kernel.info['par_type']
-        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']) - 1 # exclude effect_radius
-            # 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']) - 1  # exclude effect_radius
-            # 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
-        par_map['s_fixed'] = np.hstack(([start,start],
-                                        np.arange(start, start+num_s_fixed-2)))
-        par_map['volfraction'] = num_p_fixed
-        start += num_s_fixed-2
-        # 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-1
-        par_map['s_pd'] = np.hstack((start,
-                                     np.arange(start, start+num_s_pd-1)))
-
-        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']]
-
+    def __call__(self, details, weights, values, cutoff):
         effect_radius, vol_ratio = call_ER_VR(self.p_kernel.info, vol_pars)
 
@@ -155,6 +101,6 @@
         s_pd[0] = [effect_radius], [1.0]
 
-        p_res = self.p_kernel(p_fixed, p_pd, cutoff)
-        s_res = self.s_kernel(s_fixed, s_pd, cutoff)
+        p_res = self.p_kernel(p_details, p_weights, p_values, cutoff)
+        s_res = self.s_kernel(s_details, s_weights, s_values, cutoff)
         #print s_fixed, s_pd, p_fixed, p_pd
 
@@ -165,2 +111,14 @@
         self.q_kernel.release()
 
+def call_ER_VR(model_info, vol_pars):
+    """
+    Return effect radius and volume ratio for the model.
+    """
+    value, weight = dispersion_mesh(vol_pars)
+
+    individual_radii = model_info.ER(*value) if model_info.ER else 1.0
+    whole, part = model_info.VR(*value) if model_info.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
Index: sasmodels/resolution.py
===================================================================
--- sasmodels/resolution.py	(revision d2bb6049fb1ca2c1d60bcd3853b1d2ae889866b2)
+++ sasmodels/resolution.py	(revision 6d6508e66dc590f2ad678c70ef6fc78c3d4e684c)
@@ -476,7 +476,7 @@
     *pars* are the parameter values to use when evaluating.
     """
-    from sasmodels import core
+    from sasmodels import direct_model
     kernel = form.make_kernel([q])
-    theory = core.call_kernel(kernel, pars)
+    theory = direct_model.call_kernel(kernel, pars)
     kernel.release()
     return theory
@@ -502,5 +502,5 @@
     from scipy.integrate import romberg
 
-    par_set = set([p.name for p in form.info['parameters'].call_parameters])
+    par_set = set([p.name for p in form.info.parameters.call_parameters])
     if any(k not in par_set for k in pars.keys()):
         extra = set(pars.keys()) - par_set
@@ -557,5 +557,5 @@
     from scipy.integrate import romberg
 
-    par_set = set([p.name for p in form.info['parameters'].call_parameters])
+    par_set = set([p.name for p in form.info.parameters.call_parameters])
     if any(k not in par_set for k in pars.keys()):
         extra = set(pars.keys()) - par_set
@@ -694,7 +694,7 @@
 
     def _eval_sphere(self, pars, resolution):
-        from sasmodels import core
+        from sasmodels import direct_model
         kernel = self.model.make_kernel([resolution.q_calc])
-        theory = core.call_kernel(kernel, pars)
+        theory = direct_model.call_kernel(kernel, pars)
         result = resolution.apply(theory)
         kernel.release()
@@ -1045,4 +1045,5 @@
     import sys
     from sasmodels import core
+    from sasmodels import direct_model
     name = sys.argv[1] if len(sys.argv) > 1 else 'cylinder'
 
@@ -1065,5 +1066,5 @@
 
     kernel = model.make_kernel([resolution.q_calc])
-    theory = core.call_kernel(kernel, pars)
+    theory = direct_model.call_kernel(kernel, pars)
     Iq = resolution.apply(theory)
 
Index: sasmodels/sasview_model.py
===================================================================
--- sasmodels/sasview_model.py	(revision ee8f73486ed89c512902019a13b040e576091f49)
+++ sasmodels/sasview_model.py	(revision 6d6508e66dc590f2ad678c70ef6fc78c3d4e684c)
@@ -27,4 +27,6 @@
 from . import generate
 from . import weights
+from . import details
+from . import modelinfo
 
 def load_standard_models():
@@ -49,5 +51,5 @@
     """
     kernel_module = custom.load_custom_kernel_module(path)
-    model_info = generate.make_model_info(kernel_module)
+    model_info = modelinfo.make_model_info(kernel_module)
     return _make_model_from_info(model_info)
 
@@ -62,5 +64,5 @@
     """
     kernel_module = generate.load_kernel_module(name)
-    model_info = generate.make_model_info(kernel_module)
+    model_info = modelinfo.make_model_info(kernel_module)
     return _make_model_from_info(model_info)
 
@@ -73,5 +75,5 @@
         SasviewModel.__init__(self)
     attrs = dict(__init__=__init__, _model_info=model_info)
-    ConstructedModel = type(model_info['name'], (SasviewModel,), attrs)
+    ConstructedModel = type(model_info.name, (SasviewModel,), attrs)
     return ConstructedModel
 
@@ -85,13 +87,13 @@
         self._model = None
         model_info = self._model_info
-        parameters = model_info['parameters']
-
-        self.name = model_info['name']
-        self.description = model_info['description']
+        parameters = model_info.parameters
+
+        self.name = model_info.name
+        self.description = model_info.description
         self.category = None
         #self.is_multifunc = False
         for p in parameters.kernel_parameters:
             if p.is_control:
-                profile_axes = model_info['profile_axes']
+                profile_axes = model_info.profile_axes
                 self.multiplicity_info = [
                     p.limits[1], p.name, p.choices, profile_axes[0]
@@ -132,8 +134,8 @@
 
         ## independent parameter name and unit [string]
-        self.input_name = model_info.get("input_name", "Q")
-        self.input_unit = model_info.get("input_unit", "A^{-1}")
-        self.output_name = model_info.get("output_name", "Intensity")
-        self.output_unit = model_info.get("output_unit", "cm^{-1}")
+        self.input_name = "Q", #model_info.get("input_name", "Q")
+        self.input_unit = "A^{-1}" #model_info.get("input_unit", "A^{-1}")
+        self.output_name = "Intensity" #model_info.get("output_name", "Intensity")
+        self.output_unit = "cm^{-1}" #model_info.get("output_unit", "cm^{-1}")
 
         ## _persistency_dict is used by sas.perspectives.fitting.basepage
@@ -248,5 +250,5 @@
         # TODO: fix test so that parameter order doesn't matter
         ret = ['%s.%s' % (p.name.lower(), ext)
-               for p in self._model_info['parameters'].user_parameters()
+               for p in self._model_info.parameters.user_parameters()
                for ext in ('npts', 'nsigmas', 'width')
                if p.polydisperse]
@@ -323,5 +325,5 @@
             # Check whether we have a list of ndarrays [qx,qy]
             qx, qy = qdist
-            if not self._model_info['parameters'].has_2d:
+            if not self._model_info.parameters.has_2d:
                 return self.calculate_Iq(np.sqrt(qx ** 2 + qy ** 2))
             else:
@@ -350,7 +352,7 @@
         kernel = self._model.make_kernel(q_vectors)
         pairs = [self._get_weights(p)
-                 for p in self._model_info['parameters'].call_parameters]
-        details, weights, values = core.build_details(kernel, pairs)
-        result = kernel(details, weights, values, cutoff=self.cutoff)
+                 for p in self._model_info.parameters.call_parameters]
+        call_details, weights, values = details.build_details(kernel, pairs)
+        result = kernel(call_details, weights, values, cutoff=self.cutoff)
         kernel.q_input.release()
         kernel.release()
@@ -363,10 +365,9 @@
         :return: the value of the effective radius
         """
-        ER = self._model_info.get('ER', None)
-        if ER is None:
+        if model_info.ER is None:
             return 1.0
         else:
             values, weights = self._dispersion_mesh()
-            fv = ER(*values)
+            fv = model_info.ER(*values)
             #print(values[0].shape, weights.shape, fv.shape)
             return np.sum(weights * fv) / np.sum(weights)
@@ -378,10 +379,9 @@
         :return: the value of the volf ratio
         """
-        VR = self._model_info.get('VR', None)
-        if VR is None:
+        if model_info.VR is None:
             return 1.0
         else:
             values, weights = self._dispersion_mesh()
-            whole, part = VR(*values)
+            whole, part = model_info.VR(*values)
             return np.sum(weights * part) / np.sum(weights * whole)
 
@@ -422,6 +422,6 @@
         parameter set in the vector.
         """
-        pars = self._model_info['partype']['volume']
-        return core.dispersion_mesh([self._get_weights(p) for p in pars])
+        pars = self._model_info.partype['volume']
+        return details.dispersion_mesh([self._get_weights(p) for p in pars])
 
     def _get_weights(self, par):
