source: sasmodels/sasmodels/resolution.py @ 26b848d

core_shell_microgelscostrafo411magnetic_modelticket-1257-vesicle-productticket_1156ticket_1265_superballticket_822_more_unit_tests
Last change on this file since 26b848d was 26b848d, checked in by jhbakker, 7 years ago

SESANS optimizations

  • Property mode set to 100644
File size: 39.3 KB
Line 
1"""
2Define the resolution functions for the data.
3
4This defines classes for 1D and 2D resolution calculations.
5"""
6from __future__ import division
7
8from scipy.special import erf  # type: ignore
9from numpy import sqrt, log, log10, exp, pi  # type: ignore
10import numpy as np  # type: ignore
11
12from sasmodels import sesans
13from sasmodels.sesans import SesansTransform as SesansTransform
14
15__all__ = ["Resolution", "Perfect1D", "Pinhole1D", "Slit1D",
16           "apply_resolution_matrix", "pinhole_resolution", "slit_resolution",
17           "pinhole_extend_q", "slit_extend_q", "bin_edges",
18           "interpolate", "linear_extrapolation", "geometric_extrapolation",
19          ]
20
21MINIMUM_RESOLUTION = 1e-8
22
23
24# When extrapolating to -q, what is the minimum positive q relative to q_min
25# that we wish to calculate?
26MIN_Q_SCALE_FOR_NEGATIVE_Q_EXTRAPOLATION = 0.01
27
28class Resolution(object):
29    """
30    Abstract base class defining a 1D resolution function.
31
32    *q* is the set of q values at which the data is measured.
33
34    *q_calc* is the set of q values at which the theory needs to be evaluated.
35    This may extend and interpolate the q values.
36
37    *apply* is the method to call with I(q_calc) to compute the resolution
38    smeared theory I(q).
39    """
40    q = None  # type: np.ndarray
41    q_calc = None  # type: np.ndarray
42    def apply(self, theory):
43        """
44        Smear *theory* by the resolution function, returning *Iq*.
45        """
46        raise NotImplementedError("Subclass does not define the apply function")
47
48class Perfect1D(Resolution):
49    """
50    Resolution function to use when there is no actual resolution smearing
51    to be applied.  It has the same interface as the other resolution
52    functions, but returns the identity function.
53    """
54    def __init__(self, q):
55        self.q_calc = self.q = q
56
57    def apply(self, theory):
58        return theory
59
60class Pinhole1D(Resolution):
61    r"""
62    Pinhole aperture with q-dependent gaussian resolution.
63
64    *q* points at which the data is measured.
65
66    *q_width* gaussian 1-sigma resolution at each data point.
67
68    *q_calc* is the list of points to calculate, or None if this should
69    be estimated from the *q* and *q_width*.
70    """
71    def __init__(self, q, q_width, q_calc=None, nsigma=3):
72        #*min_step* is the minimum point spacing to use when computing the
73        #underlying model.  It should be on the order of
74        #$\tfrac{1}{10}\tfrac{2\pi}{d_\text{max}}$ to make sure that fringes
75        #are computed with sufficient density to avoid aliasing effects.
76
77        # Protect against calls with q_width=0.  The extend_q function will
78        # not extend the q if q_width is 0, but q_width must be non-zero when
79        # constructing the weight matrix to avoid division by zero errors.
80        # In practice this should never be needed, since resolution should
81        # default to Perfect1D if the pinhole geometry is not defined.
82        self.q, self.q_width = q, q_width
83        self.q_calc = (pinhole_extend_q(q, q_width, nsigma=nsigma)
84                       if q_calc is None else np.sort(q_calc))
85        self.weight_matrix = pinhole_resolution(self.q_calc, self.q,
86                                np.maximum(q_width, MINIMUM_RESOLUTION))
87
88    def apply(self, theory):
89        return apply_resolution_matrix(self.weight_matrix, theory)
90
91
92class Slit1D(Resolution):
93    """
94    Slit aperture with resolution function.
95
96    *q* points at which the data is measured.
97
98    *dqx* slit width in qx
99
100    *dqy* slit height in qy
101
102    *q_calc* is the list of points to calculate, or None if this should
103    be estimated from the *q* and *q_width*.
104
105    The *weight_matrix* is computed by :func:`slit1d_resolution`
106    """
107    def __init__(self, q, qx_width, qy_width=0., q_calc=None):
108        # Remember what width/dqy was used even though we won't need them
109        # after the weight matrix is constructed
110        self.qx_width, self.qy_width = qx_width, qy_width
111
112        # Allow independent resolution on each point even though it is not
113        # needed in practice.
114        if np.isscalar(qx_width):
115            qx_width = np.ones(len(q))*qx_width
116        else:
117            qx_width = np.asarray(qx_width)
118        if np.isscalar(qy_width):
119            qy_width = np.ones(len(q))*qy_width
120        else:
121            qy_width = np.asarray(qy_width)
122
123        self.q = q.flatten()
124        self.q_calc = slit_extend_q(q, qx_width, qy_width) \
125            if q_calc is None else np.sort(q_calc)
126        self.weight_matrix = \
127            slit_resolution(self.q_calc, self.q, qx_width, qy_width)
128
129    def apply(self, theory):
130        return apply_resolution_matrix(self.weight_matrix, theory)
131
132
133def apply_resolution_matrix(weight_matrix, theory):
134    """
135    Apply the resolution weight matrix to the computed theory function.
136    """
137    #print("apply shapes", theory.shape, weight_matrix.shape)
138    Iq = np.dot(theory[None, :], weight_matrix)
139    #print("result shape",Iq.shape)
140    return Iq.flatten()
141
142
143def pinhole_resolution(q_calc, q, q_width):
144    """
145    Compute the convolution matrix *W* for pinhole resolution 1-D data.
146
147    Each row *W[i]* determines the normalized weight that the corresponding
148    points *q_calc* contribute to the resolution smeared point *q[i]*.  Given
149    *W*, the resolution smearing can be computed using *dot(W,q)*.
150
151    *q_calc* must be increasing.  *q_width* must be greater than zero.
152    """
153    # The current algorithm is a midpoint rectangle rule.  In the test case,
154    # neither trapezoid nor Simpson's rule improved the accuracy.
155    edges = bin_edges(q_calc)
156    edges[edges < 0.0] = 0.0 # clip edges below zero
157    cdf = erf((edges[:, None] - q[None, :]) / (sqrt(2.0)*q_width)[None, :])
158    weights = cdf[1:] - cdf[:-1]
159    weights /= np.sum(weights, axis=0)[None, :]
160    return weights
161
162
163def slit_resolution(q_calc, q, width, height, n_height=30):
164    r"""
165    Build a weight matrix to compute *I_s(q)* from *I(q_calc)*, given
166    $q_\perp$ = *width* and $q_\parallel$ = *height*.  *n_height* is
167    is the number of steps to use in the integration over $q_\parallel$
168    when both $q_\perp$ and $q_\parallel$ are non-zero.
169
170    Each $q$ can have an independent width and height value even though
171    current instruments use the same slit setting for all measured points.
172
173    If slit height is large relative to width, use:
174
175    .. math::
176
177        I_s(q_i) = \frac{1}{\Delta q_\perp}
178            \int_0^{\Delta q_\perp}
179                I\left(\sqrt{q_i^2 + q_\perp^2}\right) \,dq_\perp
180
181    If slit width is large relative to height, use:
182
183    .. math::
184
185        I_s(q_i) = \frac{1}{2 \Delta q_\parallel}
186            \int_{-\Delta q_\parallel}^{\Delta q_\parallel}
187                I\left(|q_i + q_\parallel|\right) \,dq_\parallel
188
189    For a mixture of slit width and height use:
190
191    .. math::
192
193        I_s(q_i) = \frac{1}{2 \Delta q_\parallel \Delta q_\perp}
194            \int_{-\Delta q_\parallel}^{\Delta q_\parallel}
195            \int_0^{\Delta q_\perp}
196                I\left(\sqrt{(q_i + q_\parallel)^2 + q_\perp^2}\right)
197                \,dq_\perp dq_\parallel
198
199    **Definition**
200
201    We are using the mid-point integration rule to assign weights to each
202    element of a weight matrix $W$ so that
203
204    .. math::
205
206        I_s(q) = W\,I(q_\text{calc})
207
208    If *q_calc* is at the mid-point, we can infer the bin edges from the
209    pairwise averages of *q_calc*, adding the missing edges before
210    *q_calc[0]* and after *q_calc[-1]*.
211
212    For $q_\parallel = 0$, the smeared value can be computed numerically
213    using the $u$ substitution
214
215    .. math::
216
217        u_j = \sqrt{q_j^2 - q^2}
218
219    This gives
220
221    .. math::
222
223        I_s(q) \approx \sum_j I(u_j) \Delta u_j
224
225    where $I(u_j)$ is the value at the mid-point, and $\Delta u_j$ is the
226    difference between consecutive edges which have been first converted
227    to $u$.  Only $u_j \in [0, \Delta q_\perp]$ are used, which corresponds
228    to $q_j \in \left[q, \sqrt{q^2 + \Delta q_\perp}\right]$, so
229
230    .. math::
231
232        W_{ij} = \frac{1}{\Delta q_\perp} \Delta u_j
233               = \frac{1}{\Delta q_\perp} \left(
234                    \sqrt{q_{j+1}^2 - q_i^2} - \sqrt{q_j^2 - q_i^2} \right)
235            \ \text{if}\  q_j \in \left[q_i, \sqrt{q_i^2 + q_\perp^2}\right]
236
237    where $I_s(q_i)$ is the theory function being computed and $q_j$ are the
238    mid-points between the calculated values in *q_calc*.  We tweak the
239    edges of the initial and final intervals so that they lie on integration
240    limits.
241
242    (To be precise, the transformed midpoint $u(q_j)$ is not necessarily the
243    midpoint of the edges $u((q_{j-1}+q_j)/2)$ and $u((q_j + q_{j+1})/2)$,
244    but it is at least in the interval, so the approximation is going to be
245    a little better than the left or right Riemann sum, and should be
246    good enough for our purposes.)
247
248    For $q_\perp = 0$, the $u$ substitution is simpler:
249
250    .. math::
251
252        u_j = \left|q_j - q\right|
253
254    so
255
256    .. math::
257
258        W_{ij} = \frac{1}{2 \Delta q_\parallel} \Delta u_j
259            = \frac{1}{2 \Delta q_\parallel} (q_{j+1} - q_j)
260            \ \text{if}\ q_j \in
261                \left[q-\Delta q_\parallel, q+\Delta q_\parallel\right]
262
263    However, we need to support cases were $u_j < 0$, which means using
264    $2 (q_{j+1} - q_j)$ when $q_j \in \left[0, q_\parallel-q_i\right]$.
265    This is not an issue for $q_i > q_\parallel$.
266
267    For both $q_\perp > 0$ and $q_\parallel > 0$ we perform a 2 dimensional
268    integration with
269
270    .. math::
271
272        u_{jk} = \sqrt{q_j^2 - (q + (k\Delta q_\parallel/L))^2}
273            \ \text{for}\ k = -L \ldots L
274
275    for $L$ = *n_height*.  This gives
276
277    .. math::
278
279        W_{ij} = \frac{1}{2 \Delta q_\perp q_\parallel}
280            \sum_{k=-L}^L \Delta u_{jk}
281                \left(\frac{\Delta q_\parallel}{2 L + 1}\right)
282
283
284    """
285    #np.set_printoptions(precision=6, linewidth=10000)
286
287    # The current algorithm is a midpoint rectangle rule.
288    q_edges = bin_edges(q_calc) # Note: requires q > 0
289    q_edges[q_edges < 0.0] = 0.0 # clip edges below zero
290    weights = np.zeros((len(q), len(q_calc)), 'd')
291
292    #print(q_calc)
293    for i, (qi, w, h) in enumerate(zip(q, width, height)):
294        if w == 0. and h == 0.:
295            # Perfect resolution, so return the theory value directly.
296            # Note: assumes that q is a subset of q_calc.  If qi need not be
297            # in q_calc, then we can do a weighted interpolation by looking
298            # up qi in q_calc, then weighting the result by the relative
299            # distance to the neighbouring points.
300            weights[i, :] = (q_calc == qi)
301        elif h == 0:
302            weights[i, :] = _q_perp_weights(q_edges, qi, w)
303        elif w == 0:
304            in_x = 1.0 * ((q_calc >= qi-h) & (q_calc <= qi+h))
305            abs_x = 1.0*(q_calc < abs(qi - h)) if qi < h else 0.
306            #print(qi - h, qi + h)
307            #print(in_x + abs_x)
308            weights[i, :] = (in_x + abs_x) * np.diff(q_edges) / (2*h)
309        else:
310            for k in range(-n_height, n_height+1):
311                weights[i, :] += _q_perp_weights(q_edges, qi+k*h/n_height, w)
312            weights[i, :] /= 2*n_height + 1
313
314    return weights.T
315
316
317def _q_perp_weights(q_edges, qi, w):
318    # Convert bin edges from q to u
319    u_limit = np.sqrt(qi**2 + w**2)
320    u_edges = q_edges**2 - qi**2
321    u_edges[q_edges < abs(qi)] = 0.
322    u_edges[q_edges > u_limit] = u_limit**2 - qi**2
323    weights = np.diff(np.sqrt(u_edges))/w
324    #print("i, qi",i,qi,qi+width)
325    #print(q_calc)
326    #print(weights)
327    return weights
328
329
330def pinhole_extend_q(q, q_width, nsigma=3):
331    """
332    Given *q* and *q_width*, find a set of sampling points *q_calc* so
333    that each point $I(q)$ has sufficient support from the underlying
334    function.
335    """
336    q_min, q_max = np.min(q - nsigma*q_width), np.max(q + nsigma*q_width)
337    return linear_extrapolation(q, q_min, q_max)
338
339
340def slit_extend_q(q, width, height):
341    """
342    Given *q*, *width* and *height*, find a set of sampling points *q_calc* so
343    that each point I(q) has sufficient support from the underlying
344    function.
345    """
346    q_min, q_max = np.min(q-height), np.max(np.sqrt((q+height)**2 + width**2))
347
348    return geometric_extrapolation(q, q_min, q_max)
349
350
351def bin_edges(x):
352    """
353    Determine bin edges from bin centers, assuming that edges are centered
354    between the bins.
355
356    Note: this uses the arithmetic mean, which may not be appropriate for
357    log-scaled data.
358    """
359    if len(x) < 2 or (np.diff(x) < 0).any():
360        raise ValueError("Expected bins to be an increasing set")
361    edges = np.hstack([
362        x[0]  - 0.5*(x[1]  - x[0]),  # first point minus half first interval
363        0.5*(x[1:] + x[:-1]),        # mid points of all central intervals
364        x[-1] + 0.5*(x[-1] - x[-2]), # last point plus half last interval
365        ])
366    return edges
367
368
369def interpolate(q, max_step):
370    """
371    Returns *q_calc* with points spaced at most max_step apart.
372    """
373    step = np.diff(q)
374    index = step > max_step
375    if np.any(index):
376        inserts = []
377        for q_i, step_i in zip(q[:-1][index], step[index]):
378            n = np.ceil(step_i/max_step)
379            inserts.extend(q_i + np.arange(1, n)*(step_i/n))
380        # Extend a couple of fringes beyond the end of the data
381        inserts.extend(q[-1] + np.arange(1, 8)*max_step)
382        q_calc = np.sort(np.hstack((q, inserts)))
383    else:
384        q_calc = q
385    return q_calc
386
387
388def linear_extrapolation(q, q_min, q_max):
389    """
390    Extrapolate *q* out to [*q_min*, *q_max*] using the step size in *q* as
391    a guide.  Extrapolation below uses about the same size as the first
392    interval.  Extrapolation above uses about the same size as the final
393    interval.
394
395    if *q_min* is zero or less then *q[0]/10* is used instead.
396    """
397    q = np.sort(q)
398    if q_min + 2*MINIMUM_RESOLUTION < q[0]:
399        if q_min <= 0: q_min = q_min*MIN_Q_SCALE_FOR_NEGATIVE_Q_EXTRAPOLATION
400        n_low = np.ceil((q[0]-q_min) / (q[1]-q[0])) if q[1] > q[0] else 15
401        q_low = np.linspace(q_min, q[0], n_low+1)[:-1]
402    else:
403        q_low = []
404    if q_max - 2*MINIMUM_RESOLUTION > q[-1]:
405        n_high = np.ceil((q_max-q[-1]) / (q[-1]-q[-2])) if q[-1] > q[-2] else 15
406        q_high = np.linspace(q[-1], q_max, n_high+1)[1:]
407    else:
408        q_high = []
409    return np.concatenate([q_low, q, q_high])
410
411
412def geometric_extrapolation(q, q_min, q_max, points_per_decade=None):
413    r"""
414    Extrapolate *q* to [*q_min*, *q_max*] using geometric steps, with the
415    average geometric step size in *q* as the step size.
416
417    if *q_min* is zero or less then *q[0]/10* is used instead.
418
419    *points_per_decade* sets the ratio between consecutive steps such
420    that there will be $n$ points used for every factor of 10 increase
421    in *q*.
422
423    If *points_per_decade* is not given, it will be estimated as follows.
424    Starting at $q_1$ and stepping geometrically by $\Delta q$ to $q_n$
425    in $n$ points gives a geometric average of:
426
427    .. math::
428
429         \log \Delta q = (\log q_n - log q_1) / (n - 1)
430
431    From this we can compute the number of steps required to extend $q$
432    from $q_n$ to $q_\text{max}$ by $\Delta q$ as:
433
434    .. math::
435
436         n_\text{extend} = (\log q_\text{max} - \log q_n) / \log \Delta q
437
438    Substituting:
439
440    .. math::
441
442         n_\text{extend} = (n-1) (\log q_\text{max} - \log q_n)
443            / (\log q_n - log q_1)
444    """
445    q = np.sort(q)
446    if points_per_decade is None:
447        log_delta_q = (len(q) - 1) / (log(q[-1]) - log(q[0]))
448    else:
449        log_delta_q = log(10.) / points_per_decade
450    if q_min < q[0]:
451        if q_min < 0: q_min = q[0]*MIN_Q_SCALE_FOR_NEGATIVE_Q_EXTRAPOLATION
452        n_low = log_delta_q * (log(q[0])-log(q_min))
453        q_low = np.logspace(log10(q_min), log10(q[0]), np.ceil(n_low)+1)[:-1]
454    else:
455        q_low = []
456    if q_max > q[-1]:
457        n_high = log_delta_q * (log(q_max)-log(q[-1]))
458        q_high = np.logspace(log10(q[-1]), log10(q_max), np.ceil(n_high)+1)[1:]
459    else:
460        q_high = []
461    return np.concatenate([q_low, q, q_high])
462
463
464############################################################################
465# unit tests
466############################################################################
467import unittest
468
469
470def eval_form(q, form, pars):
471    """
472    Return the SAS model evaluated at *q*.
473
474    *form* is the SAS model returned from :fun:`core.load_model`.
475
476    *pars* are the parameter values to use when evaluating.
477    """
478    from sasmodels import direct_model
479    kernel = form.make_kernel([q])
480    theory = direct_model.call_kernel(kernel, pars)
481    kernel.release()
482    return theory
483
484
485def gaussian(q, q0, dq):
486    """
487    Return the Gaussian resolution function.
488
489    *q0* is the center, *dq* is the width and *q* are the points to evaluate.
490    """
491    return exp(-0.5*((q-q0)/dq)**2)/(sqrt(2*pi)*dq)
492
493
494def romberg_slit_1d(q, width, height, form, pars):
495    """
496    Romberg integration for slit resolution.
497
498    This is an adaptive integration technique.  It is called with settings
499    that make it slow to evaluate but give it good accuracy.
500    """
501    from scipy.integrate import romberg  # type: ignore
502
503    par_set = set([p.name for p in form.info.parameters.call_parameters])
504    if any(k not in par_set for k in pars.keys()):
505        extra = set(pars.keys()) - par_set
506        raise ValueError("bad parameters: [%s] not in [%s]"
507                         % (", ".join(sorted(extra)),
508                            ", ".join(sorted(pars.keys()))))
509
510    if np.isscalar(width):
511        width = [width]*len(q)
512    if np.isscalar(height):
513        height = [height]*len(q)
514    _int_w = lambda w, qi: eval_form(sqrt(qi**2 + w**2), form, pars)
515    _int_h = lambda h, qi: eval_form(abs(qi+h), form, pars)
516    # If both width and height are defined, then it is too slow to use dblquad.
517    # Instead use trapz on a fixed grid, interpolated into the I(Q) for
518    # the extended Q range.
519    #_int_wh = lambda w, h, qi: eval_form(sqrt((qi+h)**2 + w**2), form, pars)
520    q_calc = slit_extend_q(q, np.asarray(width), np.asarray(height))
521    Iq = eval_form(q_calc, form, pars)
522    result = np.empty(len(q))
523    for i, (qi, w, h) in enumerate(zip(q, width, height)):
524        if h == 0.:
525            total = romberg(_int_w, 0, w, args=(qi,),
526                            divmax=100, vec_func=True, tol=0, rtol=1e-8)
527            result[i] = total/w
528        elif w == 0.:
529            total = romberg(_int_h, -h, h, args=(qi,),
530                            divmax=100, vec_func=True, tol=0, rtol=1e-8)
531            result[i] = total/(2*h)
532        else:
533            w_grid = np.linspace(0, w, 21)[None, :]
534            h_grid = np.linspace(-h, h, 23)[:, None]
535            u_sub = sqrt((qi+h_grid)**2 + w_grid**2)
536            f_at_u = np.interp(u_sub, q_calc, Iq)
537            #print(np.trapz(Iu, w_grid, axis=1))
538            total  = np.trapz(np.trapz(f_at_u, w_grid, axis=1), h_grid[:, 0])
539            result[i] = total / (2*h*w)
540            # from scipy.integrate import dblquad
541            # r, err = dblquad(_int_wh, -h, h, lambda h: 0., lambda h: w,
542            #                  args=(qi,))
543            # result[i] = r/(w*2*h)
544
545    # r should be [float, ...], but it is [array([float]), array([float]),...]
546    return result
547
548
549def romberg_pinhole_1d(q, q_width, form, pars, nsigma=5):
550    """
551    Romberg integration for pinhole resolution.
552
553    This is an adaptive integration technique.  It is called with settings
554    that make it slow to evaluate but give it good accuracy.
555    """
556    from scipy.integrate import romberg  # type: ignore
557
558    par_set = set([p.name for p in form.info.parameters.call_parameters])
559    if any(k not in par_set for k in pars.keys()):
560        extra = set(pars.keys()) - par_set
561        raise ValueError("bad parameters: [%s] not in [%s]"
562                         % (", ".join(sorted(extra)),
563                            ", ".join(sorted(pars.keys()))))
564
565    func = lambda q, q0, dq: eval_form(q, form, pars)*gaussian(q, q0, dq)
566    total = [romberg(func, max(qi-nsigma*dqi, 1e-10*q[0]), qi+nsigma*dqi,
567                     args=(qi, dqi), divmax=100, vec_func=True,
568                     tol=0, rtol=1e-8)
569             for qi, dqi in zip(q, q_width)]
570    return np.asarray(total).flatten()
571
572
573class ResolutionTest(unittest.TestCase):
574    """
575    Test the resolution calculations.
576    """
577
578    def setUp(self):
579        self.x = 0.001*np.arange(1, 11)
580        self.y = self.Iq(self.x)
581
582    def Iq(self, q):
583        "Linear function for resolution unit test"
584        return 12.0 - 1000.0*q
585
586    def test_perfect(self):
587        """
588        Perfect resolution and no smearing.
589        """
590        resolution = Perfect1D(self.x)
591        theory = self.Iq(resolution.q_calc)
592        output = resolution.apply(theory)
593        np.testing.assert_equal(output, self.y)
594
595    def test_slit_zero(self):
596        """
597        Slit smearing with perfect resolution.
598        """
599        resolution = Slit1D(self.x, qx_width=0, qy_width=0, q_calc=self.x)
600        theory = self.Iq(resolution.q_calc)
601        output = resolution.apply(theory)
602        np.testing.assert_equal(output, self.y)
603
604    @unittest.skip("not yet supported")
605    def test_slit_high(self):
606        """
607        Slit smearing with height 0.005
608        """
609        resolution = Slit1D(self.x, qx_width=0, qy_width=0.005, q_calc=self.x)
610        theory = self.Iq(resolution.q_calc)
611        output = resolution.apply(theory)
612        answer = [
613            9.0618, 8.6402, 8.1187, 7.1392, 6.1528,
614            5.5555, 4.5584, 3.5606, 2.5623, 2.0000,
615            ]
616        np.testing.assert_allclose(output, answer, atol=1e-4)
617
618    @unittest.skip("not yet supported")
619    def test_slit_both_high(self):
620        """
621        Slit smearing with width < 100*height.
622        """
623        q = np.logspace(-4, -1, 10)
624        resolution = Slit1D(q, qx_width=0.2, qy_width=np.inf)
625        theory = 1000*self.Iq(resolution.q_calc**4)
626        output = resolution.apply(theory)
627        answer = [
628            8.85785, 8.43012, 7.92687, 6.94566, 6.03660,
629            5.40363, 4.40655, 3.40880, 2.41058, 2.00000,
630            ]
631        np.testing.assert_allclose(output, answer, atol=1e-4)
632
633    @unittest.skip("not yet supported")
634    def test_slit_wide(self):
635        """
636        Slit smearing with width 0.0002
637        """
638        resolution = Slit1D(self.x, qx_width=0.0002, qy_width=0, q_calc=self.x)
639        theory = self.Iq(resolution.q_calc)
640        output = resolution.apply(theory)
641        answer = [
642            11.0, 10.0, 9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0,
643            ]
644        np.testing.assert_allclose(output, answer, atol=1e-4)
645
646    @unittest.skip("not yet supported")
647    def test_slit_both_wide(self):
648        """
649        Slit smearing with width > 100*height.
650        """
651        resolution = Slit1D(self.x, qx_width=0.0002, qy_width=0.000001,
652                            q_calc=self.x)
653        theory = self.Iq(resolution.q_calc)
654        output = resolution.apply(theory)
655        answer = [
656            11.0, 10.0, 9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0,
657            ]
658        np.testing.assert_allclose(output, answer, atol=1e-4)
659
660    def test_pinhole_zero(self):
661        """
662        Pinhole smearing with perfect resolution
663        """
664        resolution = Pinhole1D(self.x, 0.0*self.x)
665        theory = self.Iq(resolution.q_calc)
666        output = resolution.apply(theory)
667        np.testing.assert_equal(output, self.y)
668
669    def test_pinhole(self):
670        """
671        Pinhole smearing with dQ = 0.001 [Note: not dQ/Q = 0.001]
672        """
673        resolution = Pinhole1D(self.x, 0.001*np.ones_like(self.x),
674                               q_calc=self.x)
675        theory = 12.0-1000.0*resolution.q_calc
676        output = resolution.apply(theory)
677        answer = [
678            10.44785079, 9.84991299, 8.98101708,
679            7.99906585, 6.99998311, 6.00001689,
680            5.00093415, 4.01898292, 3.15008701, 2.55214921,
681            ]
682        np.testing.assert_allclose(output, answer, atol=1e-8)
683
684
685class IgorComparisonTest(unittest.TestCase):
686    """
687    Test resolution calculations against those returned by Igor.
688    """
689
690    def setUp(self):
691        self.pars = TEST_PARS_PINHOLE_SPHERE
692        from sasmodels import core
693        self.model = core.load_model("sphere", dtype='double')
694
695    def _eval_sphere(self, pars, resolution):
696        from sasmodels import direct_model
697        kernel = self.model.make_kernel([resolution.q_calc])
698        theory = direct_model.call_kernel(kernel, pars)
699        result = resolution.apply(theory)
700        kernel.release()
701        return result
702
703    def _compare(self, q, output, answer, tolerance):
704        #err = (output - answer)/answer
705        #idx = abs(err) >= tolerance
706        #problem = zip(q[idx], output[idx], answer[idx], err[idx])
707        #print("\n".join(str(v) for v in problem))
708        np.testing.assert_allclose(output, answer, rtol=tolerance)
709
710    def test_perfect(self):
711        """
712        Compare sphere model with NIST Igor SANS, no resolution smearing.
713        """
714        pars = TEST_PARS_SLIT_SPHERE
715        data_string = TEST_DATA_SLIT_SPHERE
716
717        data = np.loadtxt(data_string.split('\n')).T
718        q, _, answer, _ = data
719        resolution = Perfect1D(q)
720        output = self._eval_sphere(pars, resolution)
721        self._compare(q, output, answer, 1e-6)
722
723    def test_pinhole(self):
724        """
725        Compare pinhole resolution smearing with NIST Igor SANS
726        """
727        pars = TEST_PARS_PINHOLE_SPHERE
728        data_string = TEST_DATA_PINHOLE_SPHERE
729
730        data = np.loadtxt(data_string.split('\n')).T
731        q, q_width, answer = data
732        resolution = Pinhole1D(q, q_width)
733        output = self._eval_sphere(pars, resolution)
734        # TODO: relative error should be lower
735        self._compare(q, output, answer, 3e-4)
736
737    def test_pinhole_romberg(self):
738        """
739        Compare pinhole resolution smearing with romberg integration result.
740        """
741        pars = TEST_PARS_PINHOLE_SPHERE
742        data_string = TEST_DATA_PINHOLE_SPHERE
743        pars['radius'] *= 5
744
745        data = np.loadtxt(data_string.split('\n')).T
746        q, q_width, answer = data
747        answer = romberg_pinhole_1d(q, q_width, self.model, pars)
748        ## Getting 0.1% requires 5 sigma and 200 points per fringe
749        #q_calc = interpolate(pinhole_extend_q(q, q_width, nsigma=5),
750        #                     2*np.pi/pars['radius']/200)
751        #tol = 0.001
752        ## The default 3 sigma and no extra points gets 1%
753        q_calc = None  # type: np.ndarray
754        tol = 0.01
755        resolution = Pinhole1D(q, q_width, q_calc=q_calc)
756        output = self._eval_sphere(pars, resolution)
757        if 0: # debug plot
758            import matplotlib.pyplot as plt  # type: ignore
759            resolution = Perfect1D(q)
760            source = self._eval_sphere(pars, resolution)
761            plt.loglog(q, source, '.')
762            plt.loglog(q, answer, '-', hold=True)
763            plt.loglog(q, output, '-', hold=True)
764            plt.show()
765        self._compare(q, output, answer, tol)
766
767    def test_slit(self):
768        """
769        Compare slit resolution smearing with NIST Igor SANS
770        """
771        pars = TEST_PARS_SLIT_SPHERE
772        data_string = TEST_DATA_SLIT_SPHERE
773
774        data = np.loadtxt(data_string.split('\n')).T
775        q, delta_qv, _, answer = data
776        resolution = Slit1D(q, qx_width=delta_qv, qy_width=0)
777        output = self._eval_sphere(pars, resolution)
778        # TODO: eliminate Igor test since it is too inaccurate to be useful.
779        # This means we can eliminate the test data as well, and instead
780        # use a generated q vector.
781        self._compare(q, output, answer, 0.5)
782
783    def test_slit_romberg(self):
784        """
785        Compare slit resolution smearing with romberg integration result.
786        """
787        pars = TEST_PARS_SLIT_SPHERE
788        data_string = TEST_DATA_SLIT_SPHERE
789
790        data = np.loadtxt(data_string.split('\n')).T
791        q, delta_qv, _, answer = data
792        answer = romberg_slit_1d(q, delta_qv, 0., self.model, pars)
793        q_calc = slit_extend_q(interpolate(q, 2*np.pi/pars['radius']/20),
794                               delta_qv[0], 0.)
795        resolution = Slit1D(q, qx_width=delta_qv, qy_width=0, q_calc=q_calc)
796        output = self._eval_sphere(pars, resolution)
797        # TODO: relative error should be lower
798        self._compare(q, output, answer, 0.025)
799
800    def test_ellipsoid(self):
801        """
802        Compare romberg integration for ellipsoid model.
803        """
804        from .core import load_model
805        pars = {
806            'scale':0.05,
807            'radius_polar':500, 'radius_equatorial':15000,
808            'sld':6, 'sld_solvent': 1,
809            }
810        form = load_model('ellipsoid', dtype='double')
811        q = np.logspace(log10(4e-5), log10(2.5e-2), 68)
812        width, height = 0.117, 0.
813        resolution = Slit1D(q, qx_width=width, qy_width=height)
814        answer = romberg_slit_1d(q, width, height, form, pars)
815        output = resolution.apply(eval_form(resolution.q_calc, form, pars))
816        # TODO: 10% is too much error; use better algorithm
817        #print(np.max(abs(answer-output)/answer))
818        self._compare(q, output, answer, 0.1)
819
820    #TODO: can sas q spacing be too sparse for the resolution calculation?
821    @unittest.skip("suppress sparse data test; not supported by current code")
822    def test_pinhole_sparse(self):
823        """
824        Compare pinhole resolution smearing with NIST Igor SANS on sparse data
825        """
826        pars = TEST_PARS_PINHOLE_SPHERE
827        data_string = TEST_DATA_PINHOLE_SPHERE
828
829        data = np.loadtxt(data_string.split('\n')).T
830        q, q_width, answer = data[:, ::20] # Take every nth point
831        resolution = Pinhole1D(q, q_width)
832        output = self._eval_sphere(pars, resolution)
833        self._compare(q, output, answer, 1e-6)
834
835
836# pinhole sphere parameters
837TEST_PARS_PINHOLE_SPHERE = {
838    'scale': 1.0, 'background': 0.01,
839    'radius': 60.0, 'sld': 1, 'sld_solvent': 6.3,
840    }
841# Q, dQ, I(Q) calculated by NIST Igor SANS package
842TEST_DATA_PINHOLE_SPHERE = """\
8430.001278 0.0002847 2538.41176383
8440.001562 0.0002905 2536.91820405
8450.001846 0.0002956 2535.13182479
8460.002130 0.0003017 2533.06217813
8470.002414 0.0003087 2530.70378586
8480.002698 0.0003165 2528.05024192
8490.002982 0.0003249 2525.10408349
8500.003266 0.0003340 2521.86667499
8510.003550 0.0003437 2518.33907750
8520.003834 0.0003539 2514.52246995
8530.004118 0.0003646 2510.41798319
8540.004402 0.0003757 2506.02690988
8550.004686 0.0003872 2501.35067884
8560.004970 0.0003990 2496.38678318
8570.005253 0.0004112 2491.16237596
8580.005537 0.0004237 2485.63911673
8590.005821 0.0004365 2479.83657083
8600.006105 0.0004495 2473.75676948
8610.006389 0.0004628 2467.40145990
8620.006673 0.0004762 2460.77293372
8630.006957 0.0004899 2453.86724627
8640.007241 0.0005037 2446.69623838
8650.007525 0.0005177 2439.25775219
8660.007809 0.0005318 2431.55421398
8670.008093 0.0005461 2423.58785521
8680.008377 0.0005605 2415.36158137
8690.008661 0.0005750 2406.87009473
8700.008945 0.0005896 2398.12841186
8710.009229 0.0006044 2389.13360806
8720.009513 0.0006192 2379.88958042
8730.009797 0.0006341 2370.39776774
8740.010080 0.0006491 2360.69528793
8750.010360 0.0006641 2350.85169027
8760.010650 0.0006793 2340.42023633
8770.010930 0.0006945 2330.11206013
8780.011220 0.0007097 2319.20109972
8790.011500 0.0007251 2308.43503981
8800.011780 0.0007404 2297.44820179
8810.012070 0.0007558 2285.83853677
8820.012350 0.0007713 2274.41290746
8830.012640 0.0007868 2262.36219581
8840.012920 0.0008024 2250.51169731
8850.013200 0.0008180 2238.45596231
8860.013490 0.0008336 2225.76495666
8870.013770 0.0008493 2213.29618391
8880.014060 0.0008650 2200.19110751
8890.014340 0.0008807 2187.34050325
8900.014620 0.0008965 2174.30529864
8910.014910 0.0009123 2160.61632548
8920.015190 0.0009281 2147.21038112
8930.015470 0.0009440 2133.62023580
8940.015760 0.0009598 2119.37907426
8950.016040 0.0009757 2105.45234903
8960.016330 0.0009916 2090.86319102
8970.016610 0.0010080 2076.60576032
8980.016890 0.0010240 2062.19214565
8990.017180 0.0010390 2047.10550219
9000.017460 0.0010550 2032.38715621
9010.017740 0.0010710 2017.52560123
9020.018030 0.0010880 2001.99124318
9030.018310 0.0011040 1986.84662060
9040.018600 0.0011200 1971.03389745
9050.018880 0.0011360 1955.61395119
9060.019160 0.0011520 1940.08291563
9070.019450 0.0011680 1923.87672225
9080.019730 0.0011840 1908.10656374
9090.020020 0.0012000 1891.66297192
9100.020300 0.0012160 1875.66789021
9110.020580 0.0012320 1859.56357196
9120.020870 0.0012490 1842.79468290
9130.021150 0.0012650 1826.50064489
9140.021430 0.0012810 1810.11533702
9150.021720 0.0012970 1793.06840882
9160.022000 0.0013130 1776.51153580
9170.022280 0.0013290 1759.87201249
9180.022570 0.0013460 1742.57354412
9190.022850 0.0013620 1725.79397319
9200.023140 0.0013780 1708.35831550
9210.023420 0.0013940 1691.45256069
9220.023700 0.0014110 1674.48561783
9230.023990 0.0014270 1656.86525366
9240.024270 0.0014430 1639.79847285
9250.024550 0.0014590 1622.68887088
9260.024840 0.0014760 1604.96421100
9270.025120 0.0014920 1587.85768129
9280.025410 0.0015080 1569.99297335
9290.025690 0.0015240 1552.84580279
9300.025970 0.0015410 1535.54074115
9310.026260 0.0015570 1517.75249337
9320.026540 0.0015730 1500.40115023
9330.026820 0.0015900 1483.03632237
9340.027110 0.0016060 1465.05942429
9350.027390 0.0016220 1447.67682181
9360.027670 0.0016390 1430.46495191
9370.027960 0.0016550 1412.49232282
9380.028240 0.0016710 1395.13182318
9390.028520 0.0016880 1377.93439837
9400.028810 0.0017040 1359.99528971
9410.029090 0.0017200 1342.67274512
9420.029370 0.0017370 1325.55375609
943"""
944
945# Slit sphere parameters
946TEST_PARS_SLIT_SPHERE = {
947    'scale': 0.01, 'background': 0.01,
948    'radius': 60000, 'sld': 1, 'sld_solvent': 4,
949    }
950# Q dQ I(Q) I_smeared(Q)
951TEST_DATA_SLIT_SPHERE = """\
9522.26097e-05 0.117 5.5781372896e+09 1.4626077708e+06
9532.53847e-05 0.117 5.0363141458e+09 1.3117318023e+06
9542.81597e-05 0.117 4.4850108103e+09 1.1594863713e+06
9553.09347e-05 0.117 3.9364658459e+09 1.0094881630e+06
9563.37097e-05 0.117 3.4019975074e+09 8.6518941303e+05
9573.92597e-05 0.117 2.4139519814e+09 6.0232158311e+05
9584.48097e-05 0.117 1.5816877820e+09 3.8739994090e+05
9595.03597e-05 0.117 9.3715407224e+08 2.2745304775e+05
9605.59097e-05 0.117 4.8387917428e+08 1.2101295768e+05
9616.14597e-05 0.117 2.0193586928e+08 6.0055107771e+04
9626.70097e-05 0.117 5.5886110911e+07 3.2749521065e+04
9637.25597e-05 0.117 3.7782348010e+06 2.6350963616e+04
9647.81097e-05 0.117 5.3407817904e+06 2.9624963314e+04
9658.36597e-05 0.117 2.7975485523e+07 3.4403962254e+04
9668.92097e-05 0.117 4.9845448282e+07 3.6130017903e+04
9679.47597e-05 0.117 6.0092588905e+07 3.3495107849e+04
9681.00310e-04 0.117 5.6823430831e+07 2.7475726279e+04
9691.05860e-04 0.117 4.3857024036e+07 2.0144282226e+04
9701.11410e-04 0.117 2.7277144760e+07 1.3647403260e+04
9711.22510e-04 0.117 3.3119334113e+06 6.6519711526e+03
9721.33610e-04 0.117 1.4412859402e+06 6.9726212813e+03
9731.44710e-04 0.117 8.5620162463e+06 8.1441335775e+03
9741.55810e-04 0.117 9.6957429033e+06 6.4559996521e+03
9751.66910e-04 0.117 4.3818341914e+06 3.6252154156e+03
9761.78010e-04 0.117 2.7448997387e+05 2.4006505342e+03
9771.89110e-04 0.117 8.0472009936e+05 2.8187789089e+03
9782.00210e-04 0.117 2.8149552834e+06 3.0915662855e+03
9792.11310e-04 0.117 2.7510907861e+06 2.3722530293e+03
9802.22410e-04 0.117 1.0053133293e+06 1.4473468311e+03
9812.33510e-04 0.117 5.8428305052e+03 1.2048540556e+03
9822.44610e-04 0.117 5.1699305004e+05 1.4625670042e+03
9832.55710e-04 0.117 1.2120227268e+06 1.5010705973e+03
9842.66810e-04 0.117 9.7896842846e+05 1.1336343426e+03
9852.77910e-04 0.117 2.5507264791e+05 8.1848818080e+02
9863.05660e-04 0.117 5.2403101181e+05 7.4913374357e+02
9873.33410e-04 0.117 5.8699343809e+04 4.4669964560e+02
9883.61160e-04 0.117 3.0844327150e+05 4.6774007542e+02
9893.88910e-04 0.117 8.3360142970e+03 2.7169550220e+02
9904.16660e-04 0.117 1.8630080583e+05 3.0710983679e+02
9914.44410e-04 0.117 3.1616804732e-01 1.7959006831e+02
9924.72160e-04 0.117 1.1299016314e+05 2.0763952339e+02
9934.99910e-04 0.117 2.9952522747e+03 1.2536542765e+02
9945.27660e-04 0.117 6.7625695649e+04 1.4013969777e+02
9955.55410e-04 0.117 7.6927460089e+03 8.2145593180e+01
9966.10910e-04 0.117 1.1229057779e+04 8.4519745643e+01
9976.66410e-04 0.117 1.3035567943e+04 8.1554625609e+01
9987.21910e-04 0.117 1.3309931343e+04 7.4437319172e+01
9997.77410e-04 0.117 1.2462626212e+04 6.4697088261e+01
10008.32910e-04 0.117 1.0912927143e+04 5.3773301044e+01
10018.88410e-04 0.117 9.0172597469e+03 4.2843375753e+01
10029.43910e-04 0.117 7.0496495917e+03 3.2771032724e+01
10039.99410e-04 0.117 5.2030483682e+03 2.4113557144e+01
10041.05491e-03 0.117 3.5988976711e+03 1.7160773658e+01
10051.11041e-03 0.117 2.2996060652e+03 1.2016626459e+01
10061.22141e-03 0.117 6.4766590598e+02 6.0373017740e+00
10071.33241e-03 0.117 4.1963483264e+01 4.5215452974e+00
10081.44341e-03 0.117 6.3370708246e+01 5.1054681903e+00
10091.55441e-03 0.117 3.0736750577e+02 5.9176165298e+00
10101.66541e-03 0.117 5.0327682399e+02 5.9815000189e+00
10111.77641e-03 0.117 5.4084331454e+02 5.1634639625e+00
10121.88741e-03 0.117 4.3488671756e+02 3.8535158148e+00
10131.99841e-03 0.117 2.6322287860e+02 2.5824997753e+00
10142.10941e-03 0.117 1.0793633150e+02 1.7315517194e+00
10152.22041e-03 0.117 1.8474448850e+01 1.4077213604e+00
10162.33141e-03 0.117 1.5864062279e+00 1.4771560682e+00
10172.44241e-03 0.117 3.2267213848e+01 1.6916253448e+00
10182.55341e-03 0.117 7.4289116207e+01 1.8274751193e+00
10192.66441e-03 0.117 9.9000521929e+01 1.7706812289e+00
1020"""
1021
1022def main():
1023    """
1024    Run tests given is sys.argv.
1025
1026    Returns 0 if success or 1 if any tests fail.
1027    """
1028    import sys
1029    import xmlrunner  # type: ignore
1030
1031    suite = unittest.TestSuite()
1032    suite.addTest(unittest.defaultTestLoader.loadTestsFromModule(sys.modules[__name__]))
1033
1034    runner = xmlrunner.XMLTestRunner(output='logs')
1035    result = runner.run(suite)
1036    return 1 if result.failures or result.errors else 0
1037
1038
1039############################################################################
1040# usage demo
1041############################################################################
1042
1043def _eval_demo_1d(resolution, title):
1044    import sys
1045    from sasmodels import core
1046    from sasmodels import direct_model
1047    name = sys.argv[1] if len(sys.argv) > 1 else 'cylinder'
1048
1049    if name == 'cylinder':
1050        pars = {'length':210, 'radius':500, 'background': 0}
1051    elif name == 'teubner_strey':
1052        pars = {'a2':0.003, 'c1':-1e4, 'c2':1e10, 'background':0.312643}
1053    elif name == 'sphere' or name == 'spherepy':
1054        pars = TEST_PARS_SLIT_SPHERE
1055    elif name == 'ellipsoid':
1056        pars = {
1057            'scale':0.05, 'background': 0,
1058            'r_polar':500, 'r_equatorial':15000,
1059            'sld':6, 'sld_solvent': 1,
1060            }
1061    else:
1062        pars = {}
1063    model_info = core.load_model_info(name)
1064    model = core.build_model(model_info)
1065
1066    kernel = model.make_kernel([resolution.q_calc])
1067    theory = direct_model.call_kernel(kernel, pars)
1068    Iq = resolution.apply(theory)
1069
1070    if isinstance(resolution, Slit1D):
1071        width, height = resolution.dqx, resolution.dqy
1072        Iq_romb = romberg_slit_1d(resolution.q, width, height, model, pars)
1073    else:
1074        dq = resolution.q_width
1075        Iq_romb = romberg_pinhole_1d(resolution.q, dq, model, pars)
1076
1077    import matplotlib.pyplot as plt  # type: ignore
1078    plt.loglog(resolution.q_calc, theory, label='unsmeared')
1079    plt.loglog(resolution.q, Iq, label='smeared', hold=True)
1080    plt.loglog(resolution.q, Iq_romb, label='romberg smeared', hold=True)
1081    plt.legend()
1082    plt.title(title)
1083    plt.xlabel("Q (1/Ang)")
1084    plt.ylabel("I(Q) (1/cm)")
1085
1086def demo_pinhole_1d():
1087    """
1088    Show example of pinhole smearing.
1089    """
1090    q = np.logspace(-4, np.log10(0.2), 400)
1091    q_width = 0.1*q
1092    resolution = Pinhole1D(q, q_width)
1093    _eval_demo_1d(resolution, title="10% dQ/Q Pinhole Resolution")
1094
1095def demo_slit_1d():
1096    """
1097    Show example of slit smearing.
1098    """
1099    q = np.logspace(-4, np.log10(0.2), 100)
1100    w = h = 0.
1101    #w = 0.000000277790
1102    w = 0.0277790
1103    #h = 0.00277790
1104    #h = 0.0277790
1105    resolution = Slit1D(q, w, h)
1106    _eval_demo_1d(resolution, title="(%g,%g) Slit Resolution"%(w, h))
1107
1108def demo():
1109    """
1110    Run the resolution demos.
1111    """
1112    import matplotlib.pyplot as plt  # type: ignore
1113    plt.subplot(121)
1114    demo_pinhole_1d()
1115    #plt.yscale('linear')
1116    plt.subplot(122)
1117    demo_slit_1d()
1118    #plt.yscale('linear')
1119    plt.show()
1120
1121
1122if __name__ == "__main__":
1123    #demo()
1124    main()
Note: See TracBrowser for help on using the repository browser.