Source code for jscatter.formel.formel

# -*- coding: utf-8 -*-
# written by Ralf Biehl at the Forschungszentrum Jülich ,
# Jülich Center for Neutron Science 1 and Institute of Complex Systems 1
#    Jscatter is a program to read, analyse and plot data
#    Copyright (C) 2015-2024  Ralf Biehl
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
#

"""
Physical equations and useful formulas as quadrature of vector functions, parallel execution,
viscosity, compressibility of water, scatteringLengthDensityCalc or sedimentationProfile.
Use scipy.constants for physical constants.

- Each topic is not enough for a single module, so this is a collection.
- All scipy functions can be used. See http://docs.scipy.org/doc/scipy/reference/special.html.
- Statistical functions http://docs.scipy.org/doc/scipy/reference/stats.html.

Mass and scattering length of all elements in Elements are taken from :
 - Mass: http://www.chem.qmul.ac.uk/iupac/AtWt/
 - Neutron scattering length: http://www.ncnr.nist.gov/resources/n-lengths/list.html

Units converted to amu for mass and nm for scattering length.

"""

import functools
import os
import pickle
from collections import deque

import numpy as np
from numpy import linalg as la
import scipy
import scipy.integrate
import scipy.signal
import scipy.optimize

from .. import parallel


_path_ = os.path.realpath(os.path.dirname(__file__))

__all__ = ['memoize', 'rotationMatrix', 'xyz2rphitheta', 'rphitheta2xyz', 'qEwaldSphere',
           'loglist', 'smooth']

#: Variable to allow printout for debugging as if debug:print('message')
debug = False


# noinspection PyIncorrectDocstring
[docs] def memoize(**memkwargs): """ A least-recently-used cache decorator to cache expensive function evaluations. Memoize caches results and retrieves from cache if same parameters are used again. This can speedup computation in a model if a part is computed with same parameters several times. During fits it may be faster to calc result for a list and take from cache. See also https://docs.python.org/3/library/functools.html cache or lru_cache Parameters ---------- function : function Function to evaluate as e.g. f(Q,a,b,c,d) memkwargs : dict Keyword args with substitute values to cache for later interpolation. Empty for normal caching of a function. E.g. memkwargs={'Q':np.r_[0:10:0.1],'t':np.r_[0:100:5]} caches with these values. The needed values can be interpolated from the returned result. See example below. maxsize : int, default 128 maximum size of the cache. Last is dropped. Returns ------- function cached function with new methods - last(i) to retrieve the ith evaluation result in cache (last is i=-1). - clear() to clear the cached results. - hitsmisses counts hits and misses. Notes ----- Only keyword arguments for the memoized function are supported!!!! Only one attribute and X are supported for fitting as .interpolate works only for two cached attributes. Examples -------- The example uses a model that computes like I(q,n,..)=F(q)*B(t,n,..). F(q) is cheap to calculate B(t,n,..) not. In the following its better to calc the function for a list of q , put it to cache and take in the fit from there. B is only calculated once inside of the function. Use it like this:: import jscatter as js import numpy as np # define some data TT=js.loglist(0.01,80,30) QQ=np.r_[0.1:1.5:0.15] # in the data we have 'q' and 'X' data=js.dynamic.finiteZimm(t=TT,q=QQ,NN=124,pmax=100,tintern=10,l=0.38,Dcm=0.01,mu=0.5,viscosity=1.001,Temp=300) # makes a unique list of all X values -> interpolation is exact for X # one may also use a smaller list of values and only interpolate tt=list(set(data.X.flatten));tt.sort() # define memoized function which will always use the here defined q and t # use correct values from data for q -> interpolation is exact for q memfZ=js.formel.memoize(q=data.q,t=tt)(js.dynamic.finiteZimm) def fitfunc(Q,Ti,NN,tint,ll,D,mu,viscosity,Temp): # use the memoized function as usual (even if given t and q are used from above definition) res= memfZ(NN=NN,tintern=tint,l=ll,Dcm=D,pmax=40,mu=mu,viscosity=viscosity,Temp=Temp) # interpolate to the here needed q and t (which is X) resint=res.interpolate(q=Q,X=Ti,deg=2)[0] return resint # do the fit data.setlimit(tint=[0.5,40],D=[0,1]) data.makeErrPlot(yscale='l') NN=20 data.fit(model=fitfunc, freepar={'tint':10,'D':0.1,}, fixpar={'NN':20,'ll':0.38/(NN/124.)**0.5,'mu':0.5,'viscosity':0.001,'Temp':300}, mapNames={'Ti':'X','Q':'q'},) Second example Use memoize as a decorator (@ in front) acting on the following function. This is a shortcut for the above and works in the same way :: # define the function to memoize @js.formel.memoize(Q=np.r_[0:3:0.2],Time=np.r_[0:50:0.5,50:100:5]) def fZ(Q,Time,NN,tintern,ll,Dcm,mu,viscosity,Temp): # finiteZimm accepts t and q as array and returns a dataList with different Q and same X=t res=js.dynamic.finiteZimm(t=Time,q=Q,NN=NN,pmax=20,tintern=tintern, l=ll,Dcm=Dcm,mu=mu,viscosity=viscosity,Temp=Temp) return res # define the fitfunc def fitfunc(Q,Ti,NN,tint,ll,D,mu,viscosity,Temp): #this is the cached result for the list of Q res= fZ(Time=Ti,Q=Q,NN=NN,tintern=tint,ll=ll,Dcm=D,mu=mu,viscosity=viscosity,Temp=Temp) # interpolate for the single Q value the cached result has again 'q' return res.interpolate(q=Q,X=Ti,deg=2)[0] # do the fit data.setlimit(tint=[0.5,40],D=[0,1]) data.makeErrPlot(yscale='l') data.fit(model=fitfunc, freepar={'tint':6,'D':0.1,}, fixpar={'NN':20,'ll':0.38/(20/124.)**0.5,'mu':0.5,'viscosity':0.001,'Temp':300}, mapNames={'Ti':'X','Q':'q'}) # the result depends on the interpolation; """ cachesize = memkwargs.pop('maxsize', 128) def _memoize(function): function.hitsmisses = [0, 0] cache = function.cache = {} deck = function.deck = deque([], maxlen=cachesize) function.last = lambda i=-1: function.cache[function.deck[i]] def clear(): while len(function.deck) > 0: del function.cache[function.deck.pop()] function.hitsmisses = [0, 0] function.clear = clear @functools.wraps(function) def _memoizer(*args, **kwargs): # make new nkwargs = dict(kwargs, **memkwargs) key = pickle.dumps(args) + pickle.dumps(nkwargs) if key in cache: function.hitsmisses[0] += 1 deck.remove(key) deck.append(key) return cache[key] else: function.hitsmisses[1] += 1 cache[key] = function(*args, **nkwargs) if len(deck) >= cachesize: del cache[deck.popleft()] deck.append(key) return cache[key] return _memoizer return _memoize
[docs] def rotationMatrix(vector, angle): """ Create a rotation matrix corresponding to rotation around vector v by a specified angle. .. math:: R = vv^T + cos(a) (I - vv^T) + sin(a) skew(v) See Notes for scipy rotation matrix. Parameters ---------- vector : array Rotation around a general vector angle : float Angle in rad Returns ------- array Rotation matrix Notes ----- A convenient way to define more complex rotations is found in `scipy.spatial.transform.Rotation <https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.transform.Rotation.html>`_ . E.g. by Euler angles and returned as rotation matrix :: from scipy.spatial.transform import Rotation as Rot R=Rot.from_euler('YZ',[90,10],1).as_matrix() References ---------- .. [1] https://en.wikipedia.org/wiki/Rotation_matrix#Rotation_matrix_from_axis_and_angle Examples -------- Examples show how to use a rotation matrix with vectors. :: import jscatter as js import numpy as np from matplotlib import pyplot R=js.formel.rotationMatrix([0,0,1],np.deg2rad(-90)) v=[1,0,0] # rotated vector rv=np.dot(R,v) # # rotate Fibonacci Grid qfib=js.formel.fibonacciLatticePointsOnSphere(300,1) qfib=qfib[qfib[:,2]<np.pi/2,:] # select half sphere qfib[:,2]*=(30/90.) # shrink to cone of 30° qfx=js.formel.rphitheta2xyz(qfib) # transform to cartesian v = [0,1,0] # rotation vector R=js.formel.rotationMatrix(v,np.deg2rad(90)) # rotation matrix around v axis Rfx=np.einsum('ij,kj->ki',R,qfx) # do rotation fig = pyplot.figure() ax = fig.add_subplot(111, projection='3d') sc=ax.scatter(qfx[:,0], qfx[:,1], qfx[:,2], s=2, color='r') sc=ax.scatter(Rfx[:,0], Rfx[:,1], Rfx[:,2], s=2, color='b') ax.scatter(0,0,0, s=55, color='g',alpha=0.5) ax.quiver([0],[0],[0],*v,color=['g']) fig.axes[0].set_title('rotate red points to blue around vector (green)') pyplot.show(block=False) # fig.savefig(js.examples.imagepath+'/rotationMatrix.jpg') .. image:: ../../examples/images/rotationMatrix.jpg :align: center :height: 300px :alt: sq2gr """ normd = np.linalg.norm(vector) assert normd != 0, 'vector norm should be >0.' d = np.array(vector, dtype=np.float64) d = d / normd eye = np.eye(3, dtype=np.float64) ddt = np.outer(d, d) skew = np.array([[0, -d[2], d[1]], [d[2], 0, -d[0]], [-d[1], d[0], 0]], dtype=np.float64) mtx = np.cos(angle) * eye + np.sin(angle) * skew + (1 -np.cos(angle)) * ddt return mtx
[docs] def xyz2rphitheta(XYZ, transpose=False): """ Transformation cartesian coordinates [X,Y,Z] to spherical coordinates [r,phi,theta]. Parameters ---------- XYZ : array Nx3 Coordinates with [x,y,z] ( XYZ.shape[1]==3). transpose : bool Transpose XYZ before transformation. Returns ------- array Nx3 Coordinates with [r,phi,theta] - phi : float azimuth -pi < phi < pi - theta : float polar angle 0 < theta < pi - r : float length Examples -------- Single coordinates :: js.formel.xyz2rphitheta([1,0,0]) Transform Fibonacci lattice on sphere to xyz coordinates :: rpc=js.formel.randomPointsInCube(10) js.formel.xyz2rphitheta(rpc) Tranformation 2D X,Y plane coordinates to r,phi coordinates (Z=0) :: rp=js.formel.xyz2rphitheta([data.X,data.Z,abs(data.X*0)],transpose=True) )[:,:2] """ xyz = np.array(XYZ, ndmin=2) if transpose: xyz = xyz.T assert xyz.shape[1] == 3, 'XYZ second dimension should be 3. Transpose it?' rpt = np.empty(xyz.shape) rpt[:, 0] = la.norm(xyz, axis=1) rpt[:, 1] = np.arctan2(xyz[:, 1], xyz[:, 0]) # arctan2 is special function for this purpose rpt[:, 2] = np.arctan2(la.norm(xyz[:, :-1], axis=1), xyz[:, 2]) return np.array(rpt.squeeze(), ndmin=np.ndim(XYZ))
[docs] def rphitheta2xyz(RPT, transpose=False): """ Transformation spherical coordinates [r,phi,theta] to cartesian coordinates [x,y,z]. Parameters ---------- RPT : array Nx3 Coordinates with [r,phi,theta] - r : float length - phi : float azimuth -pi < phi < pi - theta : float polar angle 0 < theta < pi transpose : bool Transpose RPT before transformation. Returns ------- array Nx3 [x,y,z] coordinates """ rpt = np.array(RPT, ndmin=2) if transpose: rpt = rpt.T assert rpt.shape[1] == 3, 'RPT second dimension should be 3. Transpose it?' xyz = np.zeros(rpt.shape) xyz[:, 0] = rpt[:, 0] * np.cos(rpt[:, 1]) * np.sin(rpt[:, 2]) xyz[:, 1] = rpt[:, 0] * np.sin(rpt[:, 1]) * np.sin(rpt[:, 2]) xyz[:, 2] = rpt[:, 0] * np.cos(rpt[:, 2]) return np.array(xyz.squeeze(), ndmin=np.ndim(RPT))
[docs] def qEwaldSphere(q, wavelength=0.15406, typ=None, N=60): r""" Points on Ewald sphere with different distributions. :math:`q = \vec{k_s} -\vec{k_i} =4\pi/\lambda sin(\theta/2)` with :math:`\vec{k_i} =[0,0,1]` and :math:`|\vec{k_i}| =2\pi/\lambda` Use rotation matrix to rotate to specific orientations. Parameters ---------- q : array,list Wavevectors units 1/nm wavelength : float Wavelength of radiation, default X-ray K_a. N : integer Number of points in intervals. typ : 'cart','ring','random' default='ring' Typ of q value distribution on Ewald sphere. - cart : Cartesian grid between -q_max,q_max with N points (odd to include zero). - ring : Given q values with N-points on rings of equal q. - random : N² random points on Ewald sphere between q_min and q_max. Returns ------- array : 3xN [x,y,z] coordinates Examples -------- :: import jscatter as js import numpy as np fig = js.mpl.figure(figsize=[8, 2.7],dpi=200) ax1 = fig.add_subplot(1, 4, 1, projection='3d') ax2 = fig.add_subplot(1, 4, 2, projection='3d') ax3 = fig.add_subplot(1, 4, 3, projection='3d') ax4 = fig.add_subplot(1, 4, 4, projection='3d') q0 = 2 * np.pi / 0.15406 # Ewald sphere radius |kin| q = np.r_[0.:2*q0:10j] qe = js.formel.qEwaldSphere(q) js.mpl.scatter3d(qe[0],qe[1],qe[2],ax=ax1,pointsize=1) ax1.set_title('equidistant q') q = 2*q0*np.sin(np.r_[0.:np.pi:10j]/2) qe = js.formel.qEwaldSphere(q) js.mpl.scatter3d(qe[0],qe[1],qe[2],ax=ax2,pointsize=1) ax2.set_title('equidistant angle') qe = js.formel.qEwaldSphere(q=[10],N=20,typ='cart') js.mpl.scatter3d(qe[0],qe[1],qe[2],ax=ax3,pointsize=1) ax3.set_title('cartesian grid') qe = js.formel.qEwaldSphere(q=[10,0.5*q0],N=60,typ='random') fig = js.mpl.scatter3d(qe[0],qe[1],qe[2],ax=ax4,pointsize=1) ax4.set_title('random min,max') #fig.savefig(js.examples.imagepath+'/qEwaldSphere.jpg') .. image:: ../../examples/images/qEwaldSphere.jpg :width: 90 % :align: center :alt: qEwaldSphere """ q = np.array(q) # scattering vector 4 # Ewald Sphere radius q0 = 2 * np.pi / wavelength if typ == 'cart': # cartesian grid in x.y x = y = np.r_[-q.max():q.max():1j * N] xx, yy = np.meshgrid(x, y, indexing='ij') qx = xx.flatten() qy = yy.flatten() qz = (q0 ** 2 - qx ** 2 - qy ** 2) ** 0.5 - q0 if np.all(np.isfinite(qz)): return np.stack([qx.flatten(), qy.flatten(), qz.flatten()]) else: raise ValueError('q.max() range to large for this wavelength.') elif typ == 'random': mi = 2 * np.arcsin(q.min() / 2/ q0) ma = 2 * np.arcsin(q.max() / 2/ q0) ringarea = (1 - np.cos(ma))/2 - (1 - np.cos(mi))/2 # increase number by 1/ringarea rps = rphitheta2xyz(parallel.randomPointsOnSphere(int(N ** 2 / ringarea), q0)).T - np.r_[0, 0, q0][:, None] qrps = la.norm(rps, axis=0) return rps[:, (q.min() < qrps) & (qrps < q.max())] else: # q to angle theta = 2 * np.arcsin(q / 2/ q0) phi = np.r_[0:np.pi * 2:1j * (N+1)][:-1] # q = ks - ki # assume ki=[0,0,1], theta is scattering angle, phi azimuth qx = q0 * np.sin(theta) * np.cos(phi)[:, None] qy = q0 * np.sin(theta) * np.sin(phi)[:, None] qz = q0 * (np.cos(theta) - np.ones_like(phi)[:, None]) return np.stack([qx.flatten(), qy.flatten(), qz.flatten()])
[docs] def loglist(mini=0.1, maxi=5, number=100): """ Log like sequence between mini and maxi. Parameters ---------- mini,maxi : float, default 0.1, 5 Start and endpoint. number : int, default 100 Number of points in sequence. Returns ------- ndarray """ ll = np.r_[np.log((mini if mini != 0. else 1e-6)): np.log((maxi if maxi != 0 else 1.)): (number if number != 0 else 10) * 1j] return np.exp(ll)
[docs] def smooth(data, windowlen=7, window='flat'): """ Smooth data by convolution with window function or fft/ifft. Smoothing based on position ignoring information on .X. Parameters ---------- data : array, dataArray Data to smooth. If is dataArray the .Y is smoothed and returned. windowlen : int, default = 7 The length/size of the smoothing window; should be an odd integer. Smaller 3 returns unchanged data. For 'fourier' the high frequency cutoff is 2*size_data/windowlen. window : 'hann', 'hamming', 'bartlett', 'blackman','gaussian','fourier','flattop' default ='flat' Type of window/smoothing. - 'flat' will produce a moving average smoothing. - 'gaussian' normalized Gaussian window with sigma=windowlen/7. - 'fourier' cuts high frequencies above cutoff frequency between rfft and irfft. Returns ------- array (only the smoothed array) Notes ----- 'hann', 'hamming', 'bartlett', 'blackman','gaussian', 'flat' : These methods convolve a scaled window function with the signal. The signal is prepared by introducing reflected copies of the signal (with the window size) at both ends so that transient parts are minimized in the beginning and end part of the output signal. Adapted from SciPy/Cookbook. See https://docs.scipy.org/doc/scipy-1.11.0/reference/generated/scipy.signal.get_window.html#scipy.signal.get_window fourier : The real valued signal is mirrored at left side, Fourier transformed, the high frequencies are cut and the signal is back transformed. This is the simplest form as a hard cutoff frequency is used (ideal low pass filter) and may be improved using a specific window function in frequency domain. :: rft = np.fft.rfft(np.r_[data[::-1],data]) rft[int(2*len(data)/windowlen):] = 0 smoothed = np.fft.irfft(rft) Examples -------- Usage: :: import jscatter as js import numpy as np t=np.r_[-5:5:0.01] data=np.sin(t)+np.random.randn(len(t))*0.1 y=js.formel.smooth(data) # 1d array # # smooth dataArray and replace .Y values. data2=js.dA(np.vstack([t,data])) data2.Y=js.formel.smooth(data2, windowlen=40, window='gaussian') Comparison of some filters: :: import jscatter as js import numpy as np t=np.r_[-5:5:0.01] data=js.dA(np.vstack([t,np.sin(t)+np.random.randn(len(t))*0.1])) p=js.grace() p.multi(4,2) windowlen=31 for i,window in enumerate(['flat','gaussian','hann','fourier']): p[2*i].plot(data,sy=[1,0.1,6],le='original + noise') p[2*i].plot(t,js.formel.smooth(data,windowlen,window),sy=[2,0.1,4],le='filtered') p[2*i].plot(t,np.sin(t),li=[1,0.5,1],sy=0,le='noiseless') p[2*i+1].plot(data,sy=[1,0.1,6],le='original noise') p[2*i+1].plot(t,js.formel.smooth(data,windowlen,window),sy=[2,0.1,4],le=window) p[2*i+1].plot(t,np.sin(t),li=[1,2,1],sy=0,le='noiseless') p[2*i+1].text(window,x=-2.8,y=-1.2) p[2*i+1].xaxis(min=-3,max=-1,) p[2*i+1].yaxis(min=-1.5,max=-0.2,ticklabel=[None,None,None,'opposite']) p[2*i].yaxis(label='y') p[0].legend(x=10,y=4.5) p[6].xaxis(label='x') p[7].xaxis(label='x') p[0].title(f'Comparison of smoothing windows') p[0].subtitle(f'with windowlen {windowlen}') #p.save(js.examples.imagepath+'/smooth.jpg') .. image:: ../../examples/images/smooth.jpg :align: center :height: 300px :alt: smooth """ if hasattr(data, '_isdataArray'): data = data.Y if window == 'flat': # moving average window = 'boxcar' if window == 'fourier': # real fft; cut high frequencies; inverse fft rft = np.fft.rfft(np.r_[data[::-1], data]) rft[int(2*len(data)/windowlen):] = 0 smoothed = np.fft.irfft(rft)[data.size:] return smoothed if window in ['boxcar', 'flattop', 'hann', 'hamming', 'bartlett', 'blackman', 'gaussian']: windowlen = int(np.ceil(windowlen / 2) * 2) if data.size < windowlen: raise ValueError("Input vector needs to be bigger than window size.") if windowlen < 3: return data s = np.r_[data[windowlen - 1:0:-1], data, data[-1:-windowlen:-1]] if window == 'gaussian': # gaussian window = ('gaussian', windowlen/7.) w = scipy.signal.get_window(window, windowlen) y = np.convolve(w / w.sum(), s, mode='valid') res = y[int((windowlen / 2 - 1)):int(-(windowlen / 2))] return res else: raise ValueError("Window is on of 'flat', 'hanning', 'hamming', 'bartlett', 'blackman','gaussian'")