importnumpyasnpfromscipy.specialimportwofzfromscipy.optimizeimportminimize, brentq, minimize_scalarfrommatplotlibimportpyplotaspltdefgaussian(x, sigma):
returnnp.exp(-0.5*x**2/sigma**2) / (sigma*np.sqrt(2.*np.pi))
deflorentzian(x, lambda_1_2):
return (0.5/np.pi) *lambda_1_2/ (x**2+ (0.5*lambda_1_2)**2)
deflorentzian25(x, lambda_1_2):
""" Modified Lorentzian from B. Lomanowski, et al. Nuclear Fusion 55.12 (2015) 123028, https://doi.org/10.1088/0029-5515/55/12/123028 """return (lambda_1_2**1.5/7.47443) / (np.abs(x)**2.5+ (0.5*lambda_1_2)**2.5)
defvoigt(x, sigma, lambda_1_2):
""" Exact computation of Voigt profile (gauss * lorentzian) using the Fadeeva function. Useful to test the convolution integration. :param x: Function argument. :param sigma: Gausian sigma. :param lambda_1_2: Lorentzian FWHM. """z= (x+1j*0.5*lambda_1_2) / (sigma*np.sqrt(2))
returnnp.real(wofz(z)) / (sigma*np.sqrt(2*np.pi))
defvoigt_convolution(x, fwhm_gauss, fwhm_lorentz, mg=512, ml=64, modified_lorentzian=True):
""" Computes Voigt profile as a convolution of Gaussian and Lorentzian profiles. :param x: Function argument. :param fwhm_gauss: Gausian FWHM. :param fwhm_lorentz: Lorentzian FWHM. :param mg: Number of grid points per Gausian FWHM. Default: 512. :param ml: Number of grid points per Lorentzian FWHM. Default: 64. :param modified_lorentzian: Use modified Lorentzian function. Default: True. """x=np.asarray(x)
ifx.ndim==0:
x=x[None]
res=np.zeros(len(x))
m=8*int(max(ml*fwhm_gauss/fwhm_lorentz, mg))
x1, dx1=np.linspace(-4*fwhm_gauss, 4*fwhm_gauss, m, retstep=True)
sigma=fwhm_gauss/ (2*np.sqrt(2*np.log(2)))
f1=gaussian(x1, sigma)
forix, x0inenumerate(x):
f2=lorentzian25(x1-x0, fwhm_lorentz) ifmodified_lorentzianelselorentzian(x1-x0, fwhm_lorentz)
res[ix] = (f1 @ f2) *dx1returnresdefpseudo_voigt(x, fwhm_gauss, fwhm_lorentz, voigt_fwhm_func, weight_func, modified_lorentzian=True):
""" Computes pseudo-Voigt profile as a weighted sum of Gaussian and lorentzian profiles. F_voigt(x) = weight * F_lorentz + (1. - weight) * F_gauss :param x: Function argument. :param fwhm_gauss: Gausian FWHM. :param fwhm_lorentz: Lorentzian FWHM. :param voigt_fwhm_func: A function of fwhm_gauss and fwhm_lorentz that returns approximated Voigt FWHM, fwhm_voigt. :param weight_func: A function of fwhm_lorentz/fwhm_voigt that returns a weight of Lorentzian profile. :param modified_lorentzian: Use modified Lorentzian function. Default: True. """fwhm=voigt_fwhm_func(fwhm_gauss, fwhm_lorentz)
weight=weight_func(fwhm_lorentz/fwhm)
sigma_v=0.5*fwhm/np.sqrt(2*np.log(2))
lorentz=lorentzian25(x, fwhm) ifmodified_lorentzianelselorentzian(x, fwhm)
gs=gaussian(x, sigma_v)
returnweight*lorentz+ (1-weight) *gsdefvoigt_find_fwhm(fwhm_gauss, fwhm_lorentz):
""" Finds Voigt FWHM. :param fwhm_gauss: Gausian FWHM. :param fwhm_lorentz: Lorentzian FWHM. """fvmax=voigt_convolution(0, fwhm_gauss, fwhm_lorentz)[0]
defroot_func(x):
returnvoigt_convolution(x, fwhm_gauss, fwhm_lorentz)[0] -0.5*fvmaxreturn2.*brentq(root_func, 0, fwhm_gauss+fwhm_lorentz, maxiter=1000)
deffwhm_polyfit(x, y, deg):
""" Constrained polynomial fit: a0 = 1, a1 >= 0. """defmin_func(w):
fit=1.fori, w1inenumerate(w):
fit+=w1*x**(i+1)
return ((1.-fit/y)**2).sum()
constraints= [{'type': 'ineq', 'fun': lambdaw: w[0]}]
returnnp.append(minimize(min_func, np.zeros(deg), constraints=constraints).x[::-1], [1.])
defpseudo_voigt_fwhm_fit(deg_gauss=6, deg_lorentz=6, n=1000):
""" Fits Voigt FWHM with two polynoms of given degrees. For FWHM_{G} <= FWHM_{L}: FWHM_{V} = 1. + Sum_{i=1}^{N_{G}} a_i * (FWHM_{G}/FWHM_{L})^i For FWHM_{G} > FWHM_{L}: FWHM_{V} = 1. + Sum_{i=1}^{N_{L}} b_i * (FWHM_{L}/FWHM_{G})^i :param deg_gauss: N_{G} :param deg_lorentz: N_{L} :param n: FWHM_{G}/FWHM_{L} and FWHM_{L}/FWHM_{G} grid sizes. :returns: a_i, b_i """fwhm_gauss=np.geomspace(0.01, 1, n)
fwhm_voigt=np.zeros(n)
fori, ginenumerate(fwhm_gauss):
fwhm_voigt[i] =voigt_find_fwhm(g, 1.)
poly_gauss=fwhm_polyfit(fwhm_gauss, fwhm_voigt, deg_gauss)
fwhm_lorentz=np.geomspace(0.01, 1, n)
fwhm_voigt=np.zeros(n)
fori, linenumerate(fwhm_lorentz):
fwhm_voigt[i] =voigt_find_fwhm(1., l)
poly_lorentz=fwhm_polyfit(fwhm_lorentz, fwhm_voigt, deg_lorentz)
returnpoly_gauss, poly_lorentzdefpseudo_voigt_weight_fit(fwhm_gauss, fwhm_lorentz, voigt_fwhm_func, n, relative=True):
""" Fits the Lorentzian weight in the pseudo-Voigt profile. :param fwhm_gauss: Gausian FWHM. :param fwhm_lorentz: Lorentzian FWHM. :param voigt_fwhm_func: A function of fwhm_gauss and fwhm_lorentz that returns approximated Voigt FWHM. :param n: The argument grid size for fitting the pseudo-Voigt profile. :param relative: Relative (shape) or absolute (value) fit. Default: True (relative). """fwhm_voigt=voigt_fwhm_func(fwhm_gauss, fwhm_lorentz)
ifrelative:
x=np.geomspace(0.01*fwhm_voigt, 50*fwhm_voigt, n)
else:
x=np.linspace(0, 50*fwhm_voigt, n)
fv=voigt_convolution(x, fwhm_gauss, fwhm_lorentz)
fl=lorentzian25(x, fwhm_voigt)
fg=gaussian(x, 0.5*fwhm_voigt/np.sqrt(2*np.log(2)))
deffit_func(x):
returnnp.abs(fv-x*fl- (1.-x) *fg).sum()
defrel_fit_func(x):
return ((1.- (x*fl+ (1.-x) *fg) /fv)**2).sum()
f=rel_fit_funcifrelativeelsefit_funcresult=minimize_scalar(f, bounds=(0, 1), method='Bounded')
returnresult.xn=8192nw=256poly_gauss, poly_lorentz=pseudo_voigt_fwhm_fit(deg_gauss=6, deg_lorentz=6, n=1000)
poly_gauss_func=np.poly1d(poly_gauss)
poly_lorentz_func=np.poly1d(poly_lorentz)
defvoigt_fwhm_func(fwhm_gauss, fwhm_lorentz):
iffwhm_gauss>fwhm_lorentz:
returnfwhm_gauss*poly_lorentz_func(fwhm_lorentz/fwhm_gauss)
returnfwhm_lorentz*poly_gauss_func(fwhm_gauss/fwhm_lorentz)
fwhm_lorentz=np.geomspace(0.01, 100, nw)
weight=np.zeros(nw)
fwhm_ratio=np.zeros(nw)
foriinrange(nw):
fwhm_ratio[i] =fwhm_lorentz[i] /voigt_fwhm_func(1., fwhm_lorentz[i])
print('Step {} out of {}. Fitting lorentzian weight for FWHM_lorentz/FWHM_Voigt = {}.'.format(i+1, nw, fwhm_ratio[i]))
weight[i] =pseudo_voigt_weight_fit(1., fwhm_lorentz[i], voigt_fwhm_func, n, relative=True)
deg=5poly_weight=np.polyfit(np.log(fwhm_ratio), np.log(weight), deg)
defweight_func(fwhm_ratio):
returnnp.exp(np.poly1d(poly_weight)(np.log(fwhm_ratio)))
fwhm_gauss=np.linspace(0.01, 1., 100)
fwhm_voigt=np.array([voigt_find_fwhm(fg, 1.) forfginfwhm_gauss])
fig=plt.figure()
ax=fig.add_subplot(111)
ax.plot(fwhm_gauss, fwhm_voigt, ls='-', c='tab:blue', label='Voigt FWHM')
ax.plot(fwhm_gauss, poly_gauss_func(fwhm_gauss), ls=':', c='b', label='Polynomial fit of degree {}'.format(6))
ax.set_xlabel(r'$\frac{FWHM_{Gauss}}{FWHM_{Lorentz}}$')
ax.legend(loc=0)
fwhm_lorentz=np.linspace(0.01, 1., 100)
fwhm_voigt=np.array([voigt_find_fwhm(1., fl) forflinfwhm_lorentz])
fig=plt.figure()
ax=fig.add_subplot(111)
ax.plot(fwhm_lorentz, fwhm_voigt, ls='-', c='tab:blue', label='Voigt FWHM')
ax.plot(fwhm_lorentz, poly_lorentz_func(fwhm_lorentz), ls=':', c='b', label='Polynomial fit of degree {}'.format(6))
ax.set_xlabel(r'$\frac{FWHM_{Lorentz}}{FWHM_{Gauss}}$')
ax.legend(loc=0)
fig=plt.figure()
ax=fig.add_subplot(111)
ax.plot(fwhm_ratio, weight, ls='-', c='tab:blue', label='Lorentzian weight coefficient')
ax.plot(fwhm_ratio, weight_func(fwhm_ratio), ls=':', c='b', label='Logarithmic polynomial fit of degree {}'.format(deg))
ax.set_xlabel(r'$\frac{FWHM_{Lorentz}}{FWHM_{Voigt}}$')
ax.set_yscale('log')
ax.set_xscale('log')
ax.legend(loc=0)
fwhm_lorentz= [0.01, 0.1, 0.5, 1., 2., 10.]
fig=plt.figure(figsize=(10., 10.), dpi=80)
fori, fwhm_linenumerate(fwhm_lorentz):
fwhm_v=voigt_fwhm_func(1., fwhm_l)
x=np.linspace(-40*fwhm_v, 40*fwhm_v, n)
f_voigt=voigt_convolution(x, 1., fwhm_l)
f_pseudo=pseudo_voigt(x, 1., fwhm_l, voigt_fwhm_func, weight_func)
ax1=fig.add_subplot(len(fwhm_lorentz), 3, 3*i+1)
ax1.plot(x, f_voigt, ls='-', c='tab:blue', label='True Voigt profile')
ax1.plot(x, f_pseudo, ls=':', c='b', label='Pseudo-Voigt profile')
ax1.set_yscale('log')
ax1.text(0.02, 0.95, r'$\frac{FWHM_{Lorentz}}{FWHM_{Gauss}} = $'+'{:g}'.format(fwhm_l), va='top', transform=ax1.transAxes, fontsize=11)
ax2=fig.add_subplot(len(fwhm_lorentz), 3, 3*i+2)
ax2.plot(x, f_voigt, ls='-', c='tab:blue', label='True Voigt profile')
ax2.plot(x, f_pseudo, ls=':', c='b', label='Pseudo-Voigt profile')
ax2.set_xlim(-2*fwhm_v, 2*fwhm_v)
ax3=fig.add_subplot(len(fwhm_lorentz), 3, 3*i+3)
ax3.plot(x, 1.-f_pseudo/f_voigt, ls='-', c='k', label='Residual')
ax3.set_xlim(-2*fwhm_v, 2*fwhm_v)
ifi==0:
ax1.text(0.99, 0.95, 'Logscale', ha='right', va='top', transform=ax1.transAxes)
ax2.text(0.99, 0.95, 'Linear scale', ha='right', va='top', transform=ax2.transAxes)
ax3.text(0.99, 0.95, 'Relative error', ha='right', va='top', transform=ax3.transAxes)
ax1.legend(loc=2, ncol=2, frameon=False, bbox_to_anchor=(0., 1.25))
fig.subplots_adjust(left=0.05, bottom=0.03, right=0.98, top=0.97, wspace=0.23, hspace=0.18)
plt.show()
Currently,
StarkBroadenedLineline shape model based on Lomanowski B.A. et al, 2015, Nucl. Fusion, 55 123028, only includes a modified Lorentzian for modelling static and dynamic Stark broadening of hydrogen lines:thus neglecting Zeeman splitting and Doppler broadening:$$G(\lambda'-\lambda_0, \Delta\lambda_{1/2}^{(G)}),$$ where $$\Delta\lambda_{1/2}^{(G)}=2\sqrt{2ln(2)}\sigma.$$
However, in Lomanowski et al these effects are taken into account.
The combination of Doppler and Stark broadenings gives the Voigt profile:
$$V(\lambda-\lambda_0, \Delta\lambda_{1/2}^{(G)}, \Delta\lambda_{1/2}^{(L)})=\int_{-\infty}^{\infty}G(\lambda'-\lambda_0, \Delta\lambda_{1/2}^{(G)})L(\lambda-\lambda'-\lambda_0, \Delta\lambda_{1/2}^{(L)})d\lambda'$$
Calculating such a convolution for each spectral interval at each spatial point, given that thousands of paths are being traced, would take too much time. Therefore, I propose to replace this convolution with a weighted sum (so-called pseudo-Voigt):
with$$\Delta\lambda_{1/2}^{(V)}\equiv F(\Delta\lambda_{1/2}^{(G)}, \Delta\lambda_{1/2}^{(L)}),$$ $$\eta\equiv f(\Delta\lambda_{1/2}^{(G)}, \Delta\lambda_{1/2}^{(L)}).$$
Both function, F and f can be obtained by fitting the true Voigt profile. The F function can be fitted with 6th degree polynomials of
$$x=\frac{\Delta\lambda_{1/2}^{(L)}}{\Delta\lambda_{1/2}^{(G)}} \textrm{ for } \frac{\Delta\lambda_{1/2}^{(L)}}{\Delta\lambda_{1/2}^{(G)}} \le 1$$ $$\textrm{ and of } x=\frac{\Delta\lambda_{1/2}^{(G)}}{\Delta\lambda_{1/2}^{(L)}}\textrm{ for } \frac{\Delta\lambda_{1/2}^{(G)}}{\Delta\lambda_{1/2}^{(L)}} \le 1:$$
The f function can be fitted with 5th degree logarithmic polynomial of$$\frac{\Delta\lambda_{1/2}^{(L)}}{\Delta\lambda_{1/2}^{(V)}}$$ in the range from 0.01 to 0.999:
The maximum relative error of the pseudo-Voigt approximation can reach 25%, but all the main characteristics of the spectrum: maximum intensity, FWHM, line wings are approximated well.
Here is the script that does the above fitting:
pseudo_voigt_fit.py
As for the Zeeman effect, I think for now we can do the same as in Lomanowski B.A. et al, namely model Stark and Zeeman effects separately (each Zeeman component is a Stark-Doppler line). Later we can try to interpolate tabular data from J. Rosato, Y. Marandet, R. Stamm, Journal of Quantitative Spectroscopy & Radiative Transfer 187 (2017) 333, where both effects are taken into account simultaneously on quantum level.