source: sasmodels/sasmodels/modelinfo.py @ 6d6508e

core_shell_microgelscostrafo411magnetic_modelrelease_v0.94release_v0.95ticket-1257-vesicle-productticket_1156ticket_1265_superballticket_822_more_unit_tests
Last change on this file since 6d6508e was 6d6508e, checked in by Paul Kienzle <pkienzle@…>, 8 years ago

refactor model_info from dictionary to class

  • Property mode set to 100644
File size: 25.1 KB
Line 
1from copy import copy
2from os.path import abspath, basename, splitext
3
4import numpy as np
5
6from .details import mono_details
7
8MAX_PD = 4
9
10COMMON_PARAMETERS = [
11    ["scale", "", 1, [0, np.inf], "", "Source intensity"],
12    ["background", "1/cm", 1e-3, [0, np.inf], "", "Source background"],
13]
14assert (len(COMMON_PARAMETERS) == 2
15        and COMMON_PARAMETERS[0][0]=="scale"
16        and COMMON_PARAMETERS[1][0]=="background"), "don't change common parameters"
17# assumptions about common parameters exist throughout the code, such as:
18# (1) kernel functions Iq, Iqxy, form_volume, ... don't see them
19# (2) kernel drivers assume scale is par[0] and background is par[1]
20# (3) mixture models drop the background on components and replace the scale
21#     with a scale that varies from [-inf, inf]
22# (4) product models drop the background and reassign scale
23# and maybe other places.
24# Note that scale and background cannot be coordinated parameters whose value
25# depends on the some polydisperse parameter with the current implementation
26
27def make_parameter_table(pars):
28    processed = []
29    for p in pars:
30        if not isinstance(p, list) or len(p) != 6:
31            raise ValueError("Parameter should be [name, units, default, limits, type, desc], but got %r"
32                             %str(p))
33        processed.append(parse_parameter(*p))
34    partable = ParameterTable(processed)
35    return partable
36
37def parse_parameter(name, units='', default=None,
38                    limits=(-np.inf, np.inf), type='', description=''):
39    # Parameter is a user facing class.  Do robust type checking.
40    if not isstr(name):
41        raise ValueError("expected string for parameter name %r"%name)
42    if not isstr(units):
43        raise ValueError("expected units to be a string for %s"%name)
44    # if limits is a list of strings, then this is a choice list
45    # field, and limits are 1 to length of string list
46    if isinstance(limits, list) and all(isstr(k) for k in limits):
47        choices = limits
48        limits = [0, len(choices)-1]
49    else:
50        choices = []
51    # TODO: maybe allow limits of None for (-inf, inf)
52    try:
53        low, high = limits
54        if not isinstance(low, (int, float)):
55            raise TypeError("low is not numeric")
56        if not isinstance(high, (int, float)):
57            raise TypeError("high is not numeric")
58        if low >= high:
59            raise ValueError("require low < high")
60    except Exception:
61        raise ValueError("invalid limits %s for %s"%(limits, name))
62
63    if not isinstance(default, (int, float)):
64        raise ValueError("expected default %r to be a number for %s"
65                         % (default, name))
66    if default < low or default > high:
67        raise ValueError("default value %r not in range for %s"
68                         % (default, name))
69
70    if type not in ("volume", "orientation", "sld", "magnetic", ""):
71        raise ValueError("unexpected type %r for %s" % (type, name))
72
73    if not isstr(description):
74        raise ValueError("expected description to be a string")
75
76
77    # Parameter id for name[n] does not include [n]
78    if "[" in name:
79        if not name.endswith(']'):
80            raise ValueError("Expected name[len] for vector parameter %s"%name)
81        pid, ref = name[:-1].split('[', 1)
82        ref = ref.strip()
83    else:
84        pid, ref = name, None
85
86
87    # automatically identify sld types
88    if type=='' and (pid.startswith('sld') or pid.endswith('sld')):
89        type = 'sld'
90
91    # Check if using a vector definition, name[k], as the parameter name
92    if ref:
93        if ref == '':
94            raise ValueError("Need to specify vector length for %s"%name)
95        try:
96            length = int(ref)
97            control = None
98        except Exception:
99            length = None
100            control = ref
101    else:
102        length = 1
103        control = None
104
105    # Build the parameter
106    parameter = Parameter(name=name, units=units, default=default,
107                          limits=limits, type=type, description=description)
108
109    # TODO: need better control over whether a parameter is polydisperse
110    parameter.polydisperse = type in ('orientation', 'volume')
111    parameter.relative_pd = type in ('volume')
112    parameter.choices = choices
113    parameter.length = length
114    parameter.length_control = control
115
116    return parameter
117
118
119def make_demo_pars(partable, demo):
120    """
121    Create demo parameter set from key-value pairs.
122
123    *demo* are the key-value pairs to use for the demo parameters.  Any
124    parameters not specified in *demo* are set from the *partable* defaults.
125
126    If *demo* references vector fields, such as thickness[n], then support
127    different ways of assigning the demo values, including assigning a
128    specific value (e.g., thickness3=50.0), assigning a new value to all
129    (e.g., thickness=50.0) or assigning values using list notation.
130    """
131    if demo is None:
132        result = partable.defaults
133    else:
134        pars = dict((p.id, p) for p in partable.kernel_parameters)
135        result = partable.defaults.copy()
136        vectors = dict((name,value) for name,value in demo.items()
137                       if name in pars and pars[name].length > 1)
138        if vectors:
139            scalars = dict((name, value) for name, value in demo.items()
140                           if name not in pars or pars[name].length == 1)
141            for name, value in vectors.items():
142                if np.isscalar(value):
143                    # support for the form
144                    #    demo(thickness=0, thickness2=50)
145                    for k in range(1, pars[name].length+1):
146                        key = name+str(k)
147                        if key not in scalars:
148                            scalars[key] = vectors
149                else:
150                    # supoprt for the form
151                    #    demo(thickness=[20,10,3])
152                    for (k,v) in enumerate(value):
153                        scalars[name+str(k)] = v
154            result.update(scalars)
155        else:
156            result.update(demo)
157
158    return result
159
160def prefix_parameter(par, prefix):
161    """
162    Return a copy of the parameter with its name prefixed.
163    """
164    new_par = copy(par)
165    new_par.name = prefix + par.name
166    new_par.id = prefix + par.id
167
168def suffix_parameter(par, suffix):
169    """
170    Return a copy of the parameter with its name prefixed.
171    """
172    new_par = copy(par)
173    # If name has the form x[n], replace with x_suffix[n]
174    new_par.name = par.id + suffix + par.name[len(par.id):]
175    new_par.id = par.id + suffix
176
177class Parameter(object):
178    """
179    The available kernel parameters are defined as a list, with each parameter
180    defined as a sublist with the following elements:
181
182    *name* is the name that will be used in the call to the kernel
183    function and the name that will be displayed to the user.  Names
184    should be lower case, with words separated by underscore.  If
185    acronyms are used, the whole acronym should be upper case.
186
187    *units* should be one of *degrees* for angles, *Ang* for lengths,
188    *1e-6/Ang^2* for SLDs.
189
190    *default value* will be the initial value for  the model when it
191    is selected, or when an initial value is not otherwise specified.
192
193    *limits = [lb, ub]* are the hard limits on the parameter value, used to
194    limit the polydispersity density function.  In the fit, the parameter limits
195    given to the fit are the limits  on the central value of the parameter.
196    If there is polydispersity, it will evaluate parameter values outside
197    the fit limits, but not outside the hard limits specified in the model.
198    If there are no limits, use +/-inf imported from numpy.
199
200    *type* indicates how the parameter will be used.  "volume" parameters
201    will be used in all functions.  "orientation" parameters will be used
202    in *Iqxy* and *Imagnetic*.  "magnetic* parameters will be used in
203    *Imagnetic* only.  If *type* is the empty string, the parameter will
204    be used in all of *Iq*, *Iqxy* and *Imagnetic*.  "sld" parameters
205    can automatically be promoted to magnetic parameters, each of which
206    will have a magnitude and a direction, which may be different from
207    other sld parameters. The volume parameters are used for calls
208    to form_volume within the kernel (required for volume normalization)
209    and for calls to ER and VR for effective radius and volume ratio
210    respectively.
211
212    *description* is a short description of the parameter.  This will
213    be displayed in the parameter table and used as a tool tip for the
214    parameter value in the user interface.
215
216    Additional values can be set after the parameter is created:
217
218    * *length* is the length of the field if it is a vector field
219
220    * *length_control* is the parameter which sets the vector length
221
222    * *is_control* is True if the parameter is a control parameter for a vector
223
224    * *polydisperse* is true if the parameter accepts a polydispersity
225
226    * *relative_pd* is true if that polydispersity is a portion of the
227    value (so a 10% length dipsersity would use a polydispersity value of 0.1)
228    rather than absolute dispersisity (such as an angle plus or minus
229    15 degrees).
230
231    In the usual process these values are set by :func:`make_parameter_table`
232    and :func:`parse_parameter` therein.
233    """
234    def __init__(self, name, units='', default=None, limits=(-np.inf, np.inf),
235                 type='', description=''):
236        self.id = name.split('[')[0].strip()
237        self.name = name
238        self.units = units
239        self.default = default
240        self.limits = limits
241        self.type = type
242        self.description = description
243
244        # Length and length_control will be filled in once the complete
245        # parameter table is available.
246        self.length = 1
247        self.length_control = None
248        self.is_control = False
249
250        # TODO: need better control over whether a parameter is polydisperse
251        self.polydisperse = False
252        self.relative_pd = False
253
254        # choices are also set externally.
255        self.choices = []
256
257    def as_definition(self):
258        """
259        Declare space for the variable in a parameter structure.
260
261        For example, the parameter thickness with length 3 will
262        return "double thickness[3];", with no spaces before and
263        no new line character afterward.
264        """
265        if self.length == 1:
266            return "double %s;"%self.id
267        else:
268            return "double %s[%d];"%(self.id, self.length)
269
270    def as_function_argument(self):
271        """
272        Declare the variable as a function argument.
273
274        For example, the parameter thickness with length 3 will
275        return "double *thickness", with no spaces before and
276        no comma afterward.
277        """
278        if self.length == 1:
279            return "double %s"%self.id
280        else:
281            return "double *%s"%self.id
282
283    def as_call_reference(self, prefix=""):
284        # Note: if the parameter is a struct type, then we will need to use
285        # &prefix+id.  For scalars and vectors we can just use prefix+id.
286        return prefix + self.id
287
288    def __str__(self):
289        return "<%s>"%self.name
290
291    def __repr__(self):
292        return "P<%s>"%self.name
293
294
295class ParameterTable(object):
296    """
297    ParameterTable manages the list of available parameters.
298
299    There are a couple of complications which mean that the list of parameters
300    for the kernel differs from the list of parameters that the user sees.
301
302    (1) Common parameters.  Scale and background are implicit to every model,
303    but are not passed to the kernel.
304
305    (2) Vector parameters.  Vector parameters are passed to the kernel as a
306    pointer to an array, e.g., thick[], but they are seen by the user as n
307    separate parameters thick1, thick2, ...
308
309    Therefore, the parameter table is organized by how it is expected to be
310    used. The following information is needed to set up the kernel functions:
311
312    * *kernel_parameters* is the list of parameters in the kernel parameter
313    table, with vector parameter p declared as p[].
314
315    * *iq_parameters* is the list of parameters to the Iq(q, ...) function,
316    with vector parameter p sent as p[].
317
318    * *iqxy_parameters* is the list of parameters to the Iqxy(qx, qy, ...)
319    function, with vector parameter p sent as p[].
320
321    * *form_volume_parameters* is the list of parameters to the form_volume(...)
322    function, with vector parameter p sent as p[].
323
324    Problem details, which sets up the polydispersity loops, requires the
325    following:
326
327    * *theta_offset* is the offset of the theta parameter in the kernel parameter
328    table, with vector parameters counted as n individual parameters
329    p1, p2, ..., or offset is -1 if there is no theta parameter.
330
331    * *max_pd* is the maximum number of polydisperse parameters, with vector
332    parameters counted as n individual parameters p1, p2, ...  Note that
333    this number is limited to sasmodels.modelinfo.MAX_PD.
334
335    * *npars* is the total number of parameters to the kernel, with vector
336    parameters counted as n individual parameters p1, p2, ...
337
338    * *call_parameters* is the complete list of parameters to the kernel,
339    including scale and background, with vector parameters recorded as
340    individual parameters p1, p2, ...
341
342    * *active_1d* is the set of names that may be polydisperse for 1d data
343
344    * *active_2d* is the set of names that may be polydisperse for 2d data
345
346    User parameters are the set of parameters visible to the user, including
347    the scale and background parameters that the kernel does not see.  User
348    parameters don't use vector notation, and instead use p1, p2, ...
349
350    * *control_parameters* is the
351
352    """
353    # scale and background are implicit parameters
354    COMMON = [Parameter(*p) for p in COMMON_PARAMETERS]
355
356    def __init__(self, parameters):
357        self.kernel_parameters = parameters
358        self._set_vector_lengths()
359        self._make_call_parameter_list()
360        self._categorize_parameters()
361        self._set_defaults()
362        #self._name_table= dict((p.id, p) for p in parameters)
363
364    def _set_vector_lengths(self):
365        # Sort out the length of the vector parameters such as thickness[n]
366        for p in self.kernel_parameters:
367            if p.length_control:
368                for ref in self.kernel_parameters:
369                    if ref.id == p.length_control:
370                        break
371                else:
372                    raise ValueError("no reference variable %r for %s"
373                                     % (p.length_control, p.name))
374                ref.is_control = True
375                low, high = ref.limits
376                if int(low) != low or int(high) != high or low<0 or high>20:
377                    raise ValueError("expected limits on %s to be within [0, 20]"
378                                     % ref.name)
379                p.length = high
380
381    def _set_defaults(self):
382        # Construct default values, including vector defaults
383        defaults = {}
384        for p in self.call_parameters:
385            if p.length == 1:
386                defaults[p.id] = p.default
387            else:
388                for k in range(1, p.length+1):
389                    defaults["%s%d"%(p.id, k)] = p.default
390        self.defaults = defaults
391
392    def _make_call_parameter_list(self):
393        full_list = self.COMMON[:]
394        for p in self.kernel_parameters:
395            if p.length == 1:
396                full_list.append(p)
397            else:
398                for k in range(1, p.length+1):
399                    pk = Parameter(p.id+str(k), p.units, p.default,
400                                   p.limits, p.type, p.description)
401                    pk.polydisperse = p.polydisperse
402                    pk.relative_pd = p.relative_pd
403                    full_list.append(pk)
404        self.call_parameters = full_list
405
406    def _categorize_parameters(self):
407        # Set the kernel parameters.  Assumes background and scale are the
408        # first two parameters in the parameter list, but these are not sent
409        # to the underlying kernel functions.
410        self.iq_parameters = [p for p in self.kernel_parameters
411                              if p.type not in ('orientation', 'magnetic')]
412        self.iqxy_parameters = [p for p in self.kernel_parameters
413                                if p.type != 'magnetic']
414        self.form_volume_parameters = [p for p in self.kernel_parameters
415                                       if p.type == 'volume']
416
417        # Theta offset
418        offset = 0
419        for p in self.kernel_parameters:
420            if p.name == 'theta':
421                self.theta_offset = offset
422                break
423            offset += p.length
424        else:
425            self.theta_offset = -1
426
427        # number of polydisperse parameters
428        num_pd = sum(p.length for p in self.kernel_parameters if p.polydisperse)
429        # Don't use more polydisperse parameters than are available in the model
430        # Note: we can do polydispersity on arbitrary parameters, so it is not
431        # clear that this is a good idea; it does however make the poly_details
432        # code easier to write, so we will leave it in for now.
433        self.max_pd = min(num_pd, MAX_PD)
434
435        self.npars = sum(p.length for p in self.kernel_parameters)
436
437        # true if has 2D parameters
438        self.has_2d = any(p.type in ('orientation', 'magnetic')
439                          for p in self.kernel_parameters)
440
441        self.pd_1d = set(p.name for p in self.call_parameters
442                if p.polydisperse and p.type not in ('orientation', 'magnetic'))
443        self.pd_2d = set(p.name for p in self.call_parameters
444                         if p.polydisperse and p.type != 'magnetic')
445
446    def user_parameters(self, pars={}, is2d=True):
447        """
448        Return the list of parameters for the given data type.
449
450        Vector parameters are expanded as in place.  If multiple parameters
451        share the same vector length, then the parameters will be interleaved
452        in the result.  The control parameters come first.  For example,
453        if the parameter table is ordered as::
454
455            sld_core
456            sld_shell[num_shells]
457            sld_solvent
458            thickness[num_shells]
459            num_shells
460
461        and *pars[num_shells]=2* then the returned list will be::
462
463            num_shells
464            scale
465            background
466            sld_core
467            sld_shell1
468            thickness1
469            sld_shell2
470            thickness2
471            sld_solvent
472
473        Note that shell/thickness pairs are grouped together in the result
474        even though they were not grouped in the incoming table.  The control
475        parameter is always returned first since the GUI will want to set it
476        early, and rerender the table when it is changed.
477        """
478        control = [p for p in self.kernel_parameters if p.is_control]
479
480        # Gather entries such as name[n] into groups of the same n
481        dependent = dict((p.id, []) for p in control)
482        for p in self.kernel_parameters:
483            if p.length_control is not None:
484                dependent[p.length_control].append(p)
485
486        # Gather entries such as name[4] into groups of the same length
487        fixed = {}
488        for p in self.kernel_parameters:
489            if p.length > 1 and p.length_control is None:
490                fixed.setdefault(p.length, []).append(p)
491
492        # Using the call_parameters table, we already have expanded forms
493        # for each of the vector parameters; put them in a lookup table
494        expanded_pars = dict((p.name, p) for p in self.call_parameters)
495
496        # Gather the user parameters in order
497        result = control + self.COMMON
498        for p in self.kernel_parameters:
499            if not is2d and p.type in ('orientation', 'magnetic'):
500                pass
501            elif p.is_control:
502                pass # already added
503            elif p.length_control is not None:
504                table = dependent.get(p.length_control, [])
505                if table:
506                    # look up length from incoming parameters
507                    table_length = int(pars.get(p.length_control, p.length))
508                    del dependent[p.length_control] # first entry seen
509                    for k in range(1, table_length+1):
510                        for entry in table:
511                            result.append(expanded_pars[entry.id+str(k)])
512                else:
513                    pass # already processed all entries
514            elif p.length > 1:
515                table = fixed.get(p.length, [])
516                if table:
517                    table_length = p.length
518                    del fixed[p.length]
519                    for k in range(1, table_length+1):
520                        for entry in table:
521                            result.append(expanded_pars[entry.id+str(k)])
522                else:
523                    pass # already processed all entries
524            else:
525                result.append(p)
526
527        return result
528
529
530
531def isstr(x):
532    # TODO: 2-3 compatible tests for str, including unicode strings
533    return isinstance(x, str)
534
535def make_model_info(kernel_module):
536    info = ModelInfo()
537    #print("make parameter table", kernel_module.parameters)
538    parameters = make_parameter_table(kernel_module.parameters)
539    demo = make_demo_pars(parameters, getattr(kernel_module, 'demo', None))
540    filename = abspath(kernel_module.__file__)
541    kernel_id = splitext(basename(filename))[0]
542    name = getattr(kernel_module, 'name', None)
543    if name is None:
544        name = " ".join(w.capitalize() for w in kernel_id.split('_'))
545
546    info.id = kernel_id  # string used to load the kernel
547    info.filename = abspath(kernel_module.__file__)
548    info.name = name
549    info.title = getattr(kernel_module, 'title', name+" model")
550    info.description = getattr(kernel_module, 'description', 'no description')
551    info.parameters = parameters
552    info.demo = demo
553    info.composition = None
554    info.docs = kernel_module.__doc__
555    info.category = getattr(kernel_module, 'category', None)
556    info.single = getattr(kernel_module, 'single', True)
557    info.structure_factor = getattr(kernel_module, 'structure_factor', False)
558    info.profile_axes = getattr(kernel_module, 'profile_axes', ['x','y'])
559    info.variant_info = getattr(kernel_module, 'invariant_info', None)
560    info.demo = getattr(kernel_module, 'demo', None)
561    info.source = getattr(kernel_module, 'source', [])
562    info.tests = getattr(kernel_module, 'tests', [])
563    info.ER = getattr(kernel_module, 'ER', None)
564    info.VR = getattr(kernel_module, 'VR', None)
565    info.form_volume = getattr(kernel_module, 'form_volume', None)
566    info.Iq = getattr(kernel_module, 'Iq', None)
567    info.Iqxy = getattr(kernel_module, 'Iqxy', None)
568    info.profile = getattr(kernel_module, 'profile', None)
569    info.sesans = getattr(kernel_module, 'sesans', None)
570
571    # Precalculate the monodisperse parameter details
572    info.mono_details = mono_details(info)
573    return info
574
575class ModelInfo(object):
576    """
577    Interpret the model definition file, categorizing the parameters.
578
579    The module can be loaded with a normal python import statement if you
580    know which module you need, or with __import__('sasmodels.model.'+name)
581    if the name is in a string.
582
583    The *model_info* structure contains the following fields:
584
585    * *id* is the id of the kernel
586    * *name* is the display name of the kernel
587    * *filename* is the full path to the module defining the file (if any)
588    * *title* is a short description of the kernel
589    * *description* is a long description of the kernel (this doesn't seem
590      very useful since the Help button on the model page brings you directly
591      to the documentation page)
592    * *docs* is the docstring from the module.  Use :func:`make_doc` to
593    * *category* specifies the model location in the docs
594    * *parameters* is the model parameter table
595    * *single* is True if the model allows single precision
596    * *structure_factor* is True if the model is useable in a product
597    * *variant_info* contains the information required to select between
598      model variants (e.g., the list of cases) or is None if there are no
599      model variants
600    * *par_type* categorizes the model parameters. See
601      :func:`categorize_parameters` for details.
602    * *demo* contains the *{parameter: value}* map used in compare (and maybe
603      for the demo plot, if plots aren't set up to use the default values).
604      If *demo* is not given in the file, then the default values will be used.
605    * *tests* is a set of tests that must pass
606    * *source* is the list of library files to include in the C model build
607    * *Iq*, *Iqxy*, *form_volume*, *ER*, *VR* and *sesans* are python functions
608      implementing the kernel for the module, or None if they are not
609      defined in python
610    * *composition* is None if the model is independent, otherwise it is a
611      tuple with composition type ('product' or 'mixture') and a list of
612      *model_info* blocks for the composition objects.  This allows us to
613      build complete product and mixture models from just the info.
614    """
615    id = None
616    filename = None
617    name = None
618    title = None
619    description = None
620    parameters = None
621    demo = None
622    composition = None
623    docs = None
624    category = None
625    single = None
626    structure_factor = None
627    profile_axes = None
628    variant_info = None
629    demo = None
630    source = None
631    tests = None
632    ER = None
633    VR = None
634    form_volume = None
635    Iq = None
636    Iqxy = None
637    profile = None
638    sesans = None
639    mono_details = None
640
641    def __init__(self):
642        pass
643
644
Note: See TracBrowser for help on using the repository browser.