source: sasmodels/explore/multiscat.py @ 54a5024

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

multiscat: use correct weightings for the multiscattering contributions

  • Property mode set to 100644
File size: 11.4 KB
Line 
1#!/usr/bin/env python
2r"""
3Multiple scattering calculator
4
5Calculate multiple scattering using 2D FFT convolution.
6
7Usage:
8
9    -p, --probability: the scattering probability
10    -q, --qmax: that max q that you care about
11    -w, --window: the extension window (q is calculated for qmax*window)
12    -n, --nq: the number of mesh points (dq = qmax*window/nq)
13    -r, --random: generate a random parameter set
14    -2, --2d: perform the calculation for an oriented pattern
15    model_name
16    model_par=value ...
17
18Assume the probability of scattering is $p$. After each scattering event,
19$1-p$ neutrons will leave the system and go to the detector, and the remaining
20$p$ will scatter again.
21
22Let the scattering probability for $n$ scattering event at $q$ be $f_n(q)$,
23where
24.. math:: f_1(q) = \frac{I_1(q)}{\int I_1(q) dq}
25for $I_1(q)$, the single scattering from the system. After two scattering
26events, the scattering probability will be the convolution of the first
27scattering and itself, or $f_2(q) = (f_1*f_1)(q)$.  After $n$ events it will be
28$f_n(q) = (f_1 * \cdots * f_1)(q)$.  The total scattering is calculated
29as the weighted sum of $f_k$, with weights following the Poisson distribution
30.. math:: P(k; \lambda) = \frac{\lambda^k e^{-\lambda}}{k!}
31for $\lambda$ determined by the total thickness divided by the mean
32free path between scattering, giving
33.. math::
34    I(q) = \sum_{k=0}^\infty P(k; \lambda) f_k(q)
35The $k=0$ term is ignored since it involves no scattering.
36We cut the series when cumulative probability is less than cutoff $C=99\%$,
37which is $\max n$ such that
38.. math::
39    \frac{\sum_{k=1}^n \frac{P(k; \lambda)}{1 - P(0; \lambda)} < C
40
41Using the convolution theorem, where
42$F = \mathcal{F}(f)$ is the Fourier transform,
43.. math:: f * g = \mathcal{F}^{-1}\{\mathcal{F}\{f\} \cdot \mathcal{F}\{g\}\}
44so
45.. math:: f * \ldots * f = \mathcal{F}^{-1}\{ F^n \}
46Since the Fourier transform is a linear operator, we can move the polynomial
47expression for the convolution into the transform, giving
48.. math::
49    I(q) = \mathcal{F}^{-1}\left\{ \sum_{k=1}^{n} P(k; \lambda) F^k \right\}
50In the dilute limit $L \rightarrow 0$ only the $k=1$ term is active,
51and so
52.. math::
53    P(1; \lambda) = \lambda e{-\lambda} = \int I_1(q) dq
54therefore we compute
55.. math::
56    I(q) = \int I_1(q) dq \mathcal{F}^{-1}\left\{
57        \sum_{l=1}^{n} \frac{P(k; \lambda)}{P(1; \lambda))} F^k \right\}
58
59For speed we may use the fast fourier transform with a power of two.
60The resulting $I(q)$ will be linearly spaced and likely heavily oversampled.
61The usual pinhole or slit resolution calculation can performed from these
62calculated values.
63"""
64
65from __future__ import print_function, division
66
67import argparse
68import time
69
70import numpy as np
71from scipy.special import gamma
72
73from sasmodels import core
74from sasmodels import compare
75from sasmodels import resolution2d
76from sasmodels.resolution import Resolution, bin_edges
77from sasmodels.data import empty_data1D, empty_data2D, plot_data
78from sasmodels.direct_model import call_kernel
79
80
81class MultipleScattering(Resolution):
82    def __init__(self, qmax, nq, probability, is2d, window=2, power=0):
83        self.qmax = qmax
84        self.nq = nq
85        self.power = power
86        self.probability = probability
87        self.is2d = is2d
88        self.window = window
89
90        q_range = qmax * window
91        q = np.linspace(-q_range, q_range, nq)
92        qx, qy = np.meshgrid(q, q)
93
94        if is2d:
95            q_calc = [qx.flatten(), qy.flatten()]
96        else:
97            q_range_corners = np.sqrt(2.) * q_range
98            nq_corners = int(np.sqrt(2.) * nq/2)
99            q_corners = np.linspace(0, q_range_corners, nq_corners+1)[1:]
100            q_calc = [q_corners]
101            self._qxy = np.sqrt(qx**2 + qy**2)
102            self._edges = bin_edges(q_corners)
103            self._norm = np.histogram(self._qxy, bins=self._edges)[0]
104
105        self.q_calc = q_calc
106        self.q_range = q_range
107
108    def apply(self, theory, background=0.0, outfile="", plot=False):
109        #t0 = time.time()
110        if self.is2d:
111            Iq_calc = theory
112        else:
113            q_corners = self.q_calc[0]
114            Iq_calc = np.interp(self._qxy, q_corners, theory)
115        Iq_calc = Iq_calc.reshape(self.nq, self.nq)
116        #plotxy(Iq_calc); import pylab; pylab.figure()
117        if self.power > 0:
118            Iqxy = scattering_power(Iq_calc, self.power)
119            powers = []
120        else:
121            Iqxy, powers = multiple_scattering(
122                Iq_calc, self.probability,
123                coverage=0.99,
124                return_powers=(outfile!="") or plot,
125                )
126        #print("multiple scattering calc time", time.time()-t0)
127
128        #plotxy(Iqxy); import pylab; pylab.figure()
129        if self.is2d:
130            if outfile and powers:
131                data = np.vstack([Ipower.flatten() for Ipower in powers]).T
132                np.savetxt(outfile + "_powers.txt", data)
133            if outfile:
134                data = np.vstack(Iq_calc).T
135                np.savetxt(outfile + ".txt", data)
136            if plot:
137                import pylab
138                plotxy(Iq_calc)
139                pylab.title("single scattering")
140                pylab.figure()
141
142            return Iqxy + background
143        else:
144            # circular average, no anti-aliasing
145            Iq = np.histogram(self._qxy, bins=self._edges, weights=Iqxy)[0]/self._norm
146            if powers:
147                Iq_powers = [np.histogram(self._qxy, bins=self._edges, weights=Ipower)[0]/self._norm
148                             for Ipower in powers]#  or powers[:5] for the first five
149
150            if outfile:
151                data = np.vstack([q_corners, theory, Iq]).T
152                np.savetxt(outfile + ".txt", data)
153            if outfile and powers:
154                # circular average, no anti-aliasing for individual powers
155                data = np.vstack([q_corners] + Iq_powers).T
156                np.savetxt(outfile + "_powers.txt", data)
157            if plot:
158                import pylab
159                plotxy(Iqxy)
160                pylab.title("multiple scattering")
161                pylab.figure()
162            if plot and powers:
163                import pylab
164                for k, Ipower in enumerate(Iq_powers):
165                    pylab.loglog(q_corners, Ipower, label="scattering=%d"%(k+1))
166                pylab.legend()
167                pylab.figure()
168
169            return q_corners, Iq + background
170
171def scattering_power(Iq, n):
172    """
173    Calculate the nth scattering power as a distribution.  To get the
174    weighted contribution, scale by $\lambda^k e^{-\lambda}/k!$.
175    """
176    scale = np.sum(Iq)
177    F = _forward_fft(Iq/scale)
178    result = _inverse_fft(F**n)
179    return result
180
181def multiple_scattering(Iq, p, coverage=0.99, return_powers=False):
182    """
183    Compute multiple scattering for I(q) given scattering probability p.
184
185    Given a probability p of scattering with the thickness, the expected
186    number of scattering events, $\lambda$ is $-\log(1 - p)$, giving a
187    Poisson weighted sum of single, double, triple, etc. scattering patterns.
188    The number of patterns used is based on coverage (default 99%).
189    """
190    L = -np.log(1-p)
191    num_scatter = truncated_poisson_invcdf(coverage, L)
192
193    # Compute multiple scattering via convolution.
194    scale = np.sum(Iq)
195    F = _forward_fft(Iq/scale)
196    coeffs = [L**(k-1)/gamma(k+1) for k in range(1, num_scatter+1)]
197    multiple_scattering = F * np.polyval(coeffs[::-1], F)
198    result = scale * _inverse_fft(multiple_scattering)
199
200    if return_powers:
201        powers = [scale * _inverse_fft(F**k) for k in range(1, num_scatter+1)]
202    else:
203        powers = []
204
205    return result, powers
206
207def truncated_poisson_invcdf(p, L):
208    """
209    Return smallest k such that cdf(k; L) > p from the truncated Poisson
210    probability excluding k=0
211    """
212    # pmf(k; L) = L**k * exp(-L) / (k! * (1-exp(-L))
213    cdf = 0
214    pmf = -np.exp(-L) / np.expm1(-L)
215    k = 0
216    while cdf < p:
217        k += 1
218        pmf *= L/k
219        cdf += pmf
220    return k
221
222def _forward_fft(Iq):
223    # Prepare padded array and forward transform
224    nq = Iq.shape[0]
225    half_nq = nq//2
226    frame = np.zeros((2*nq, 2*nq))
227    frame[:half_nq, :half_nq] = Iq[half_nq:, half_nq:]
228    frame[-half_nq:, :half_nq] = Iq[:half_nq, half_nq:]
229    frame[:half_nq, -half_nq:] = Iq[half_nq:, :half_nq]
230    frame[-half_nq:, -half_nq:] = Iq[:half_nq, :half_nq]
231    fourier_frame = np.fft.fft2(frame)
232    return fourier_frame
233
234def _inverse_fft(fourier_frame):
235    # Invert the transform and recover the transformed data
236    nq = fourier_frame.shape[0]//2
237    half_nq = nq//2
238    frame = np.fft.ifft2(fourier_frame).real
239    Iq = np.empty((nq, nq))
240    Iq[half_nq:, half_nq:] = frame[:half_nq, :half_nq]
241    Iq[:half_nq, half_nq:] = frame[-half_nq:, :half_nq]
242    Iq[half_nq:, :half_nq] = frame[:half_nq, -half_nq:]
243    Iq[:half_nq, :half_nq] = frame[-half_nq:, -half_nq:]
244    return Iq
245
246def parse_pars(model, opts):
247    # type: (ModelInfo, argparse.Namespace) -> Dict[str, float]
248
249    seed = np.random.randint(1000000) if opts.random and opts.seed < 0 else opts.seed
250    compare_opts = {
251        'info': (model.info, model.info),
252        'use_demo': False,
253        'seed': seed,
254        'mono': True,
255        'magnetic': False,
256        'values': opts.pars,
257        'show_pars': True,
258        'is2d': opts.is2d,
259    }
260    pars, pars2 = compare.parse_pars(compare_opts)
261    return pars
262
263
264def main():
265    parser = argparse.ArgumentParser(
266        description="Compute multiple scattering",
267        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
268        )
269    parser.add_argument('-k', '--power', type=int, default=0, help="show pattern for nth scattering")
270    parser.add_argument('-p', '--probability', type=float, default=0.1, help="scattering probability")
271    parser.add_argument('-n', '--nq', type=int, default=1024, help='number of mesh points')
272    parser.add_argument('-q', '--qmax', type=float, default=0.5, help='max q')
273    parser.add_argument('-w', '--window', type=float, default=2.0, help='q calc = q max * window')
274    parser.add_argument('-2', '--2d', dest='is2d', action='store_true', help='oriented sample')
275    parser.add_argument('-s', '--seed', default=-1, help='random pars with given seed')
276    parser.add_argument('-r', '--random', action='store_true', help='random pars with random seed')
277    parser.add_argument('-o', '--outfile', type=str, default="", help='random pars with random seed')
278    parser.add_argument('model', type=str, help='sas model name such as cylinder')
279    parser.add_argument('pars', type=str, nargs='*', help='model parameters such as radius=30')
280    opts = parser.parse_args()
281    assert opts.nq%2 == 0, "require even # points"
282
283    model = core.load_model(opts.model)
284    pars = parse_pars(model, opts)
285    res = MultipleScattering(opts.qmax, opts.nq, opts.probability, opts.is2d,
286                             window=opts.window, power=opts.power)
287    kernel = model.make_kernel(res.q_calc)
288    #print(pars)
289    bg = pars.get('background', 0.0)
290    pars['background'] = 0.0
291    Iq_calc = call_kernel(kernel, pars)
292    Iq = res.apply(Iq_calc, background=bg, outfile=opts.outfile, plot=True)
293    plotxy(Iq)
294    import pylab
295    if opts.power > 0:
296        pylab.title('scattering power %d'%opts.power)
297    else:
298        pylab.title('multiple scattering with fraction %g'%opts.probability)
299    pylab.show()
300
301def plotxy(Iq):
302    import pylab
303    if isinstance(Iq, tuple):
304        q, Iq = Iq
305        pylab.loglog(q, Iq)
306    else:
307        data = Iq+0.
308        data[Iq <= 0] = np.min(Iq[Iq>0])/2
309        pylab.imshow(np.log10(data))
310    #import pylab; pylab.show()
311
312if __name__ == "__main__":
313    main()
Note: See TracBrowser for help on using the repository browser.