source: sasmodels/sasmodels/core.py @ b3af1c2

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

py.test needs kernelcl import to succeed even if pyopencl is not available

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