source: sasmodels/sasmodels/core.py @ 71b751d

core_shell_microgelsmagnetic_modelticket-1257-vesicle-productticket_1156ticket_1265_superballticket_822_more_unit_tests
Last change on this file since 71b751d was 71b751d, checked in by Paul Kienzle <pkienzle@…>, 6 years ago

update remaining form factors to use Fq interface

  • Property mode set to 100644
File size: 13.9 KB
Line 
1"""
2Core model handling routines.
3"""
4from __future__ import print_function
5
6__all__ = [
7    "list_models", "load_model", "load_model_info",
8    "build_model", "precompile_dlls",
9    ]
10
11import os
12from os.path import basename, join as joinpath
13from glob import glob
14import re
15
16import numpy as np # type: ignore
17
18from . import generate
19from . import modelinfo
20from . import product
21from . import mixture
22from . import kernelpy
23from . import kernelcl
24from . import kerneldll
25from . import custom
26
27# pylint: disable=unused-import
28try:
29    from typing import List, Union, Optional, Any
30    from .kernel import KernelModel
31    from .modelinfo import ModelInfo
32except ImportError:
33    pass
34# pylint: enable=unused-import
35
36CUSTOM_MODEL_PATH = os.environ.get('SAS_MODELPATH', "")
37if CUSTOM_MODEL_PATH == "":
38    CUSTOM_MODEL_PATH = joinpath(os.path.expanduser("~"), ".sasmodels", "custom_models")
39    #if not os.path.isdir(CUSTOM_MODEL_PATH):
40    #    os.makedirs(CUSTOM_MODEL_PATH)
41
42# TODO: refactor composite model support
43# The current load_model_info/build_model does not reuse existing model
44# definitions when loading a composite model, instead reloading and
45# rebuilding the kernel for each component model in the expression.  This
46# is fine in a scripting environment where the model is built when the script
47# starts and is thrown away when the script ends, but may not be the best
48# solution in a long-lived application.  This affects the following functions:
49#
50#    load_model
51#    load_model_info
52#    build_model
53
54KINDS = ("all", "py", "c", "double", "single", "opencl", "1d", "2d",
55         "nonmagnetic", "magnetic")
56def list_models(kind=None):
57    # type: (str) -> List[str]
58    """
59    Return the list of available models on the model path.
60
61    *kind* can be one of the following:
62
63        * all: all models
64        * py: python models only
65        * c: compiled models only
66        * single: models which support single precision
67        * double: models which require double precision
68        * opencl: controls if OpenCL is supperessed
69        * 1d: models which are 1D only, or 2D using abs(q)
70        * 2d: models which can be 2D
71        * magnetic: models with an sld
72        * nommagnetic: models without an sld
73
74    For multiple conditions, combine with plus.  For example, *c+single+2d*
75    would return all oriented models implemented in C which can be computed
76    accurately with single precision arithmetic.
77    """
78    if kind and any(k not in KINDS for k in kind.split('+')):
79        raise ValueError("kind not in " + ", ".join(KINDS))
80    files = sorted(glob(joinpath(generate.MODEL_PATH, "[a-zA-Z]*.py")))
81    available_models = [basename(f)[:-3] for f in files]
82    if kind and '+' in kind:
83        all_kinds = kind.split('+')
84        condition = lambda name: all(_matches(name, k) for k in all_kinds)
85    else:
86        condition = lambda name: _matches(name, kind)
87    selected = [name for name in available_models if condition(name)]
88
89    return selected
90
91def _matches(name, kind):
92    if kind is None or kind == "all":
93        return True
94    info = load_model_info(name)
95    pars = info.parameters.kernel_parameters
96    if kind == "py" and callable(info.Iq):
97        return True
98    elif kind == "c" and not callable(info.Iq):
99        return True
100    elif kind == "double" and not info.single:
101        return True
102    elif kind == "single" and info.single:
103        return True
104    elif kind == "opencl" and info.opencl:
105        return True
106    elif kind == "2d" and any(p.type == 'orientation' for p in pars):
107        return True
108    elif kind == "1d" and all(p.type != 'orientation' for p in pars):
109        return True
110    elif kind == "magnetic" and any(p.type == 'sld' for p in pars):
111        return True
112    elif kind == "nonmagnetic" and any(p.type != 'sld' for p in pars):
113        return True
114    return False
115
116def load_model(model_name, dtype=None, platform='ocl'):
117    # type: (str, str, str) -> KernelModel
118    """
119    Load model info and build model.
120
121    *model_name* is the name of the model, or perhaps a model expression
122    such as sphere*hardsphere or sphere+cylinder.
123
124    *dtype* and *platform* are given by :func:`build_model`.
125    """
126    return build_model(load_model_info(model_name),
127                       dtype=dtype, platform=platform)
128
129def load_model_info(model_string):
130    # type: (str) -> modelinfo.ModelInfo
131    """
132    Load a model definition given the model name.
133
134    *model_string* is the name of the model, or perhaps a model expression
135    such as sphere*cylinder or sphere+cylinder. Use '@' for a structure
136    factor product, e.g. sphere@hardsphere. Custom models can be specified by
137    prefixing the model name with 'custom.', e.g. 'custom.MyModel+sphere'.
138
139    This returns a handle to the module defining the model.  This can be
140    used with functions in generate to build the docs or extract model info.
141    """
142    if "+" in model_string:
143        parts = [load_model_info(part)
144                 for part in model_string.split("+")]
145        return mixture.make_mixture_info(parts, operation='+')
146    elif "*" in model_string:
147        parts = [load_model_info(part)
148                 for part in model_string.split("*")]
149        return mixture.make_mixture_info(parts, operation='*')
150    elif "@" in model_string:
151        p_info, q_info = [load_model_info(part)
152                          for part in model_string.split("@")]
153        return product.make_product_info(p_info, q_info)
154    # We are now dealing with a pure model
155    elif "custom." in model_string:
156        pattern = "custom.([A-Za-z0-9_-]+)"
157        result = re.match(pattern, model_string)
158        if result is None:
159            raise ValueError("Model name in invalid format: " + model_string)
160        model_name = result.group(1)
161        # Use ModelName to find the path to the custom model file
162        model_path = joinpath(CUSTOM_MODEL_PATH, model_name + ".py")
163        if not os.path.isfile(model_path):
164            raise ValueError("The model file {} doesn't exist".format(model_path))
165        kernel_module = custom.load_custom_kernel_module(model_path)
166        return modelinfo.make_model_info(kernel_module)
167    kernel_module = generate.load_kernel_module(model_string)
168    return modelinfo.make_model_info(kernel_module)
169
170
171def build_model(model_info, dtype=None, platform="ocl"):
172    # type: (modelinfo.ModelInfo, str, str) -> KernelModel
173    """
174    Prepare the model for the default execution platform.
175
176    This will return an OpenCL model, a DLL model or a python model depending
177    on the model and the computing platform.
178
179    *model_info* is the model definition structure returned from
180    :func:`load_model_info`.
181
182    *dtype* indicates whether the model should use single or double precision
183    for the calculation.  Choices are 'single', 'double', 'quad', 'half',
184    or 'fast'.  If *dtype* ends with '!', then force the use of the DLL rather
185    than OpenCL for the calculation.
186
187    *platform* should be "dll" to force the dll to be used for C models,
188    otherwise it uses the default "ocl".
189    """
190    composition = model_info.composition
191    if composition is not None:
192        composition_type, parts = composition
193        models = [build_model(p, dtype=dtype, platform=platform) for p in parts]
194        if composition_type == 'mixture':
195            return mixture.MixtureModel(model_info, models)
196        elif composition_type == 'product':
197            P, S = models
198            return product.ProductModel(model_info, P, S)
199        else:
200            raise ValueError('unknown mixture type %s'%composition_type)
201
202    # If it is a python model, return it immediately
203    if callable(model_info.Iq):
204        return kernelpy.PyModel(model_info)
205
206    numpy_dtype, fast, platform = parse_dtype(model_info, dtype, platform)
207    source = generate.make_source(model_info)
208    if platform == "dll":
209        #print("building dll", numpy_dtype)
210        return kerneldll.load_dll(source['dll'], model_info, numpy_dtype)
211    else:
212        #print("building ocl", numpy_dtype)
213        return kernelcl.GpuModel(source, model_info, numpy_dtype, fast=fast)
214
215def precompile_dlls(path, dtype="double"):
216    # type: (str, str) -> List[str]
217    """
218    Precompile the dlls for all builtin models, returning a list of dll paths.
219
220    *path* is the directory in which to save the dlls.  It will be created if
221    it does not already exist.
222
223    This can be used when build the windows distribution of sasmodels
224    which may be missing the OpenCL driver and the dll compiler.
225    """
226    numpy_dtype = np.dtype(dtype)
227    if not os.path.exists(path):
228        os.makedirs(path)
229    compiled_dlls = []
230    for model_name in list_models():
231        model_info = load_model_info(model_name)
232        if not callable(model_info.Iq):
233            source = generate.make_source(model_info)['dll']
234            old_path = kerneldll.SAS_DLL_PATH
235            try:
236                kerneldll.SAS_DLL_PATH = path
237                dll = kerneldll.make_dll(source, model_info, dtype=numpy_dtype)
238            finally:
239                kerneldll.SAS_DLL_PATH = old_path
240            compiled_dlls.append(dll)
241    return compiled_dlls
242
243def parse_dtype(model_info, dtype=None, platform=None):
244    # type: (ModelInfo, str, str) -> (np.dtype, bool, str)
245    """
246    Interpret dtype string, returning np.dtype and fast flag.
247
248    Possible types include 'half', 'single', 'double' and 'quad'.  If the
249    type is 'fast', then this is equivalent to dtype 'single' but using
250    fast native functions rather than those with the precision level
251    guaranteed by the OpenCL standard.  'default' will choose the appropriate
252    default for the model and platform.
253
254    Platform preference can be specfied ("ocl" vs "dll"), with the default
255    being OpenCL if it is availabe.  If the dtype name ends with '!' then
256    platform is forced to be DLL rather than OpenCL.
257
258    This routine ignores the preferences within the model definition.  This
259    is by design.  It allows us to test models in single precision even when
260    we have flagged them as requiring double precision so we can easily check
261    the performance on different platforms without having to change the model
262    definition.
263    """
264    # Assign default platform, overriding ocl with dll if OpenCL is unavailable
265    # If opencl=False OpenCL is switched off
266    if platform is None:
267        platform = "ocl"
268    if not kernelcl.use_opencl() or not model_info.opencl:
269        platform = "dll"
270
271    # Check if type indicates dll regardless of which platform is given
272    if dtype is not None and dtype.endswith('!'):
273        platform = "dll"
274        dtype = dtype[:-1]
275
276    # Convert special type names "half", "fast", and "quad"
277    fast = (dtype == "fast")
278    if fast:
279        dtype = "single"
280    elif dtype == "quad":
281        dtype = "longdouble"
282    elif dtype == "half":
283        dtype = "float16"
284
285    # Convert dtype string to numpy dtype.
286    if dtype is None or dtype == "default":
287        numpy_dtype = (generate.F32 if platform == "ocl" and model_info.single
288                       else generate.F64)
289    else:
290        numpy_dtype = np.dtype(dtype)
291
292    # Make sure that the type is supported by opencl, otherwise use dll
293    if platform == "ocl":
294        env = kernelcl.environment()
295        if not env.has_type(numpy_dtype):
296            platform = "dll"
297            if dtype is None:
298                numpy_dtype = generate.F64
299
300    return numpy_dtype, fast, platform
301
302def list_models_main():
303    # type: () -> None
304    """
305    Run list_models as a main program.  See :func:`list_models` for the
306    kinds of models that can be requested on the command line.
307    """
308    import sys
309    kind = sys.argv[1] if len(sys.argv) > 1 else "all"
310    print("\n".join(list_models(kind)))
311
312def test_composite_order():
313    def test_models(fst, snd):
314        """Confirm that two models produce the same parameters"""
315        fst = load_model(fst)
316        snd = load_model(snd)
317        # Un-disambiguate parameter names so that we can check if the same
318        # parameters are in a pair of composite models. Since each parameter in
319        # the mixture model is tagged as e.g., A_sld, we ought to use a
320        # regex subsitution s/^[A-Z]+_/_/, but removing all uppercase letters
321        # is good enough.
322        fst = [[x for x in p.name if x == x.lower()] for p in fst.info.parameters.kernel_parameters]
323        snd = [[x for x in p.name if x == x.lower()] for p in snd.info.parameters.kernel_parameters]
324        assert sorted(fst) == sorted(snd), "{} != {}".format(fst, snd)
325
326    def build_test(first, second):
327        test = lambda description: test_models(first, second)
328        description = first + " vs. " + second
329        return test, description
330
331    yield build_test(
332        "cylinder+sphere",
333        "sphere+cylinder")
334    yield build_test(
335        "cylinder*sphere",
336        "sphere*cylinder")
337    yield build_test(
338        "cylinder@hardsphere*sphere",
339        "sphere*cylinder@hardsphere")
340    yield build_test(
341        "barbell+sphere*cylinder@hardsphere",
342        "sphere*cylinder@hardsphere+barbell")
343    yield build_test(
344        "barbell+cylinder@hardsphere*sphere",
345        "cylinder@hardsphere*sphere+barbell")
346    yield build_test(
347        "barbell+sphere*cylinder@hardsphere",
348        "barbell+cylinder@hardsphere*sphere")
349    yield build_test(
350        "sphere*cylinder@hardsphere+barbell",
351        "cylinder@hardsphere*sphere+barbell")
352    yield build_test(
353        "barbell+sphere*cylinder@hardsphere",
354        "cylinder@hardsphere*sphere+barbell")
355    yield build_test(
356        "barbell+cylinder@hardsphere*sphere",
357        "sphere*cylinder@hardsphere+barbell")
358
359def test_composite():
360    # type: () -> None
361    """Check that model load works"""
362    #Test the the model produces the parameters that we would expect
363    model = load_model("cylinder@hardsphere*sphere")
364    actual = [p.name for p in model.info.parameters.kernel_parameters]
365    target = ("sld sld_solvent radius length theta phi volfraction"
366              " beta_mode A_sld A_sld_solvent A_radius").split()
367    assert target == actual, "%s != %s"%(target, actual)
368
369if __name__ == "__main__":
370    list_models_main()
Note: See TracBrowser for help on using the repository browser.