Peak fitting using damped harmonic oscillator model

This tutorial demonstrates how one can analyze the current correlations and dynamic structure factor by fitting peaks to analytical expressions for a damped harmonic oscillators (DHO).

Damped harmonic oscillator

The details of the DHO model can be found in the theoretical background. In short, the position autocorrelation function of a DHO is given by \begin{equation*} F(t) = A \mathrm{e}^{- t/\tau} \left ( \cos{ \omega_e t} + \frac{1}{\tau\omega_e}\sin{ \omega_e t}\right ), \end{equation*} where \(\omega_e = \sqrt{\omega_0^2 - 1/\tau^2}\) is the angular eigenfrequency of the DHO. The Fourier transform of the above is \begin{equation*} S(\omega) = A\frac{2\Gamma \omega_0^2}{(\omega^2 - \omega_0^2)^2 + (\Gamma\omega)^2}, \end{equation*} where \(\omega_0\) is the natural angular frequency, \(\Gamma\) the damping (lifetime \(\tau=2/\Gamma\)) and \(A\) an arbitrary amplitude of the DHO.

Note that for current correlations (and their respective Fourier transforms) one must use the velocity autocorrelation function for the DHO, whereas for the intermediate scattering function (and dynamic structure factor) the position autocorrelation function is used.

[1]:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from dynasor import read_sample_from_npz
from dynasor.units import radians_per_fs_to_meV
from scipy.optimize import curve_fit

Al FCC

First, we will look at the current correlations, \(C_\text{L}(\boldsymbol{q}, t)\) and \(C_\text{T}(\boldsymbol{q}, t)\), in Al FCC at a temperature of T = 900 K and fit these with DHO models.

The full dispersion from current correlations is shown below, where blue corresponds to longitudinal modes and red to transverse modes.

current_heatmap.png

Here, we will only consider fitting the current correlations for one q-point, namely \(\boldsymbol{q}=[0.5, 0.5, 0.0]\) which sits between K and \(\Gamma\).

This data is stored as a pandas dataframe in a csv file, obtained via sample.to_dataframe(q_index) (see here), and can be read using pandas.

[2]:
# read data
alat = 4.05
df = pd.read_csv('Al_fcc_size24_T900_qindex38.csv')
print(df)

w = df.omega
t = df.time
Clqt = df.Clqt
Ctqt = df.Ctqt
Clqw = df.Clqw
Ctqw = df.Ctqw
         omega     time       Clqt   Clqt_X_X      Clqw  Clqw_X_X       Ctqt  \
0     0.000000      0.0  27.747023  27.747023  2.632250  2.632250  27.804061
1     0.000157      5.0  26.699390  26.699390  3.549897  3.549897  27.373739
2     0.000314     10.0  23.540678  23.540678  2.247025  2.247025  26.121036
3     0.000471     15.0  18.562543  18.562543  3.889430  3.889430  24.101959
4     0.000628     20.0  12.212002  12.212002  3.864050  3.864050  21.406585
...        ...      ...        ...        ...       ...       ...        ...
7996  1.256009  39980.0  -0.075647  -0.075647 -0.014731 -0.014731   0.051283
7997  1.256166  39985.0  -0.062453  -0.062453 -0.012500 -0.012500   0.070549
7998  1.256323  39990.0  -0.045894  -0.045894 -0.011104 -0.011104   0.086015
7999  1.256480  39995.0  -0.024718  -0.024718 -0.004054 -0.004054   0.097392
8000  1.256637  40000.0   0.001406   0.001406 -0.002088 -0.002088   0.105129

       Ctqt_X_X       Ctqw   Ctqw_X_X       Fqt   Fqt_coh  Fqt_coh_X_X  \
0     27.804061   2.776075   2.776075  0.012116  0.012116     0.012116
1     27.373739  34.885753  34.885753  0.011681  0.011681     0.011681
2     26.121036  66.727080  66.727080  0.010453  0.010453     0.010453
3     24.101959  48.080473  48.080473  0.008530  0.008530     0.008530
4     21.406585  44.940541  44.940541  0.006063  0.006063     0.006063
...         ...        ...        ...       ...       ...          ...
7996   0.051283  -0.004358  -0.004358  0.000061  0.000061     0.000061
7997   0.070549   0.002383   0.002383  0.000052  0.000052     0.000052
7998   0.086015   0.005731   0.005731  0.000039  0.000039     0.000039
7999   0.097392   0.010066   0.010066  0.000022  0.000022     0.000022
8000   0.105129  -0.001084  -0.001084  0.000003  0.000003     0.000003

               Sqw       Sqw_coh   Sqw_coh_X_X
0     8.061254e-02  8.061254e-02  8.061254e-02
1     1.425915e-01  1.425915e-01  1.425915e-01
2     1.536352e-01  1.536352e-01  1.536352e-01
3     8.122096e-02  8.122096e-02  8.122096e-02
4     1.981069e-01  1.981069e-01  1.981069e-01
...            ...           ...           ...
7996  3.456208e-06  3.456208e-06  3.456208e-06
7997  3.995259e-07  3.995259e-07  3.995259e-07
7998  3.718448e-06  3.718448e-06  3.718448e-06
7999 -2.560956e-06 -2.560956e-06 -2.560956e-06
8000 -3.734499e-06 -3.734499e-06 -3.734499e-06

[8001 rows x 16 columns]

First, we fit the data to a DHO in the time domain, although it could just as well be done in the frequency domain. The fitting can be accomplished in many ways, but here we simply use a least-square fit via SciPy’s curve_fit function. It is important to select some reasonable starting parameters in order to make the fit converge to a good solution. Note that while the fit is carried out in time domain, the resulting fit parameters (\(\omega_0, \Gamma, A\)) can directly be used in the frequency domain too.

[3]:
from dynasor.tools.damped_harmonic_oscillator import acf_velocity_dho, psd_velocity_dho


# fitting longitudinal, in time domain
p0 = [30/radians_per_fs_to_meV, 2/radians_per_fs_to_meV, 1e4]  # initial guesses for parameters w0, Gamma, A
params_L, _ = curve_fit(acf_velocity_dho, t, Clqt, p0)

w0_L = params_L[0] * radians_per_fs_to_meV
gamma_L = params_L[1] * radians_per_fs_to_meV
print(f' Fitting results for C_L(q, t): w0 {w0_L:.2f} meV, gamma {gamma_L:.2f} meV')

# generate resulting dho data, in both time and frequency domain
t_lin = np.linspace(0, t.max(), 100000)
w_lin = np.linspace(0, w.max(), 100000)

Clqt_fit = acf_velocity_dho(t_lin, *params_L)
Clqw_fit = psd_velocity_dho(w_lin, *params_L)
 Fitting results for C_L(q, t): w0 35.00 meV, gamma 4.13 meV

Next, for fitting the tranvserse current correlation we need to employ a sum of two DHOs.

[4]:
def two_acfs(t, w01, gamma1, A1, w02, gamma2, A2):
    acf1 = acf_velocity_dho(t, w01, gamma1, A1)
    acf2 = acf_velocity_dho(t, w02, gamma2, A2)
    return acf1 + acf2


def two_psds(t, w01, gamma1, A1, w02, gamma2, A2):
    psd1 = psd_velocity_dho(t, w01, gamma1, A1)
    psd2 = psd_velocity_dho(t, w02, gamma2, A2)
    return psd1 + psd2

# fitting transverse, in time domain
p0 = [15/radians_per_fs_to_meV, 2/radians_per_fs_to_meV, 1e4,
      30/radians_per_fs_to_meV, 2/radians_per_fs_to_meV, 1e4]
params_T, _ = curve_fit(two_acfs, t, Ctqt, p0)

w0_T1 = params_T[0] * radians_per_fs_to_meV
gamma_T1 = params_T[1] * radians_per_fs_to_meV
w0_T2 = params_T[3] * radians_per_fs_to_meV
gamma_T2 = params_T[4] * radians_per_fs_to_meV
print(f' Fitting results for C_T(q, t): w0 {w0_T1:.2f} meV, gamma {gamma_T1:.2f} meV and '
      f' w0 {w0_T2:.2f} meV, gamma {gamma_T2:.2f}')


# generate resulting dho data, in both time and frequency domain
t_lin = np.linspace(0, t.max(), 100000)
w_lin = np.linspace(0, w.max(), 100000)

Ctqt_fit = two_acfs(t_lin, *params_T)
Ctqw_fit = two_psds(w_lin, *params_T)
 Fitting results for C_T(q, t): w0 16.79 meV, gamma 1.83 meV and  w0 26.33 meV, gamma 1.78

Next, we plot the raw data and resulting fits.

[5]:
# plot params
t_lim = [0, 1500]
w_lim1 = [25, 45]
w_lim2 = [10, 35]

y_lim1 = [-30, 30]
y_lim2 = [0, 12000]
alpha = 0.65


# plot setup
fig = plt.figure(figsize=(8.2, 5.4), dpi=130)
gs = plt.GridSpec(2, 2)

ax1 = plt.subplot(gs[0, 0])
ax2 = plt.subplot(gs[0, 1])
ax3 = plt.subplot(gs[1, 0])
ax4 = plt.subplot(gs[1, 1])


# plotting time domain
ax1.plot(t, Clqt, '.', label='Data')
ax1.plot(t_lin, Clqt_fit, '-r', alpha=alpha, label='Fit')
ax1.plot(t_lin, Clqt[0] * np.exp(-t_lin * params_L[1]/2), '--k', alpha=0.8, lw=1.0, label=r'$exp(-\Gamma t /2)$')

ax1.set_xlabel('Time (fs)')
ax1.set_ylabel(r'$C_\text{L}(\boldsymbol{q}, t)$')
ax1.legend(loc=1, frameon=False)
ax1.set_title('Longitudinal')

ax2.plot(t, Ctqt, '.')
ax2.plot(t_lin, Ctqt_fit, '-r', alpha=alpha)
ax2.set_xlabel('Time (fs)')
ax2.set_ylabel(r'$C_\text{T}(\boldsymbol{q}, t)$')
ax2.set_title('Transverse')

for ax in [ax1, ax2]:
    ax.set_xlim(t_lim)
    ax.set_ylim(y_lim1)

# plotting frequency domain
ax3.plot(w * radians_per_fs_to_meV, Clqw, '.')
ax3.plot(w_lin * radians_per_fs_to_meV, Clqw_fit, '-r', alpha=alpha)

ax3.set_xlabel('Frequency (meV)')
ax3.set_ylabel(r'$C_\text{L}(\boldsymbol{q}, \omega)$')
ax3.set_xlim(w_lim1)
ax3.set_ylim(y_lim2)
ax3.annotate('', xy=(w0_L - gamma_L/2 , Clqw_fit.max() / 2.0), xytext=(w0_L + gamma_L/2, Clqw_fit.max() / 2.0), arrowprops=dict(arrowstyle='<->'))
ax3.text(w0_L, 0.75*Clqw_fit.max()/2, r'$\Gamma$')


ax4.plot(w * radians_per_fs_to_meV, Ctqw, '.')
ax4.plot(w_lin * radians_per_fs_to_meV, Ctqw_fit, '-r', alpha=alpha)

ax4.set_xlabel('Frequency (meV)')
ax4.set_ylabel(r'$C_\text{T}(\boldsymbol{q}, \omega)$')
ax4.set_xlim(w_lim2)
ax4.set_ylim(y_lim2)

fig.align_ylabels()
fig.tight_layout()
../_images/tutorials_dho_peak_fitting_10_0.png

Note that, while the fits are carried out in the time domain, the corresponding DHO spectra match the data well in the frequency domain too. Generally, it is a good idea to plot the resulting fit in both the time and frequency domain, regardless of where the fit was performed, as a check of the procedure. In a simple system like this, is should not matter in which domain the fit is performed, but it might be easier to obtain a converged fit in one of the domains when more complex systems are considered.

Cubic BaZrO3

Additionally, we will consider a more anharmonic system, the cubic phase of BaZrO3 (BZO), with \(S(\boldsymbol{q}, \omega)\) data from this paper. We will analyze the low-frequency octahedral tilt phonon mode at the R-point (\(\boldsymbol{q}=[1.5, 0.5, 0.5]\)), at T = 300 K for three different pressure (0 GPa, 8 GPa, and 15.6 GPa). Note that this phonon mode is related to the phase transition between the cubic and tetragonal phases which occurs at around 16 GPa. The sample objects only contains \(S(\boldsymbol{q}, \omega)\) for this specific q-point.

Note that for overdamped modes the DHO is better represented using two timescales \(\tau_L\) and \(\tau_S\) instead of \(\omega_0\) and \(\Gamma\). These are defined as \begin{equation*} \tau_\text{S,L} = \frac{\tau}{1 \pm \sqrt{1-(\omega_0\tau)^2}}, \end{equation*} where \(\tau=2/\Gamma\).

[6]:
from dynasor.tools.damped_harmonic_oscillator import psd_position_dho


# read data
P_vals =  [0.0, 8.0, 15.6]
data_dict = dict()
for P in P_vals:
    fname = f'BaZrO3_sample_T300_P{P:.1f}.npz'
    sample = read_sample_from_npz(fname)
    data_dict[P] = radians_per_fs_to_meV * sample.omega, sample.Sqw[0]

Next, we visualize the three different spectra.

[7]:
# figure setup
xlim = [0, 20]
ylim = [1, 8000]
fig, ax = plt.subplots(1, 1, figsize=(4.5, 2.6), dpi=200)


for P, (w, Sqw) in data_dict.items():
    ax.plot(w, Sqw, '-', label=f'P={P} GPa')

ax.legend(loc=1, frameon=False)
ax.set_xlabel('Frequency (meV)')
ax.set_ylabel(r'$S(\mathbf{q}, \omega)$')
ax.set_xlim(xlim)
ax.set_ylim(ylim)
ax.set_yscale('log')
fig.tight_layout()
../_images/tutorials_dho_peak_fitting_15_0.png

For P = 0 GPa we see two clear peaks at around 8 meV (octahedral tilt mode) and around 13 meV (acoustic Barium mode). Additionally, there exist multiple modes above 20 meV, but here we focus on the low frequency mode. For P = 15.6 GPa (close to the phase transition) one can clearly see that the tilt mode exhbits overdamped behaviour.

Next, we fit the corresponding spectra to DHOs, i.e., the fits are performed in the frequency domain.

[8]:
from dynasor.tools.damped_harmonic_oscillator import psd_position_dho

# figure setup
xlim = [0, 12]
ylim = [1, 25000]
fig, axes = plt.subplots(3, 1, figsize=(4.5, 5.6), dpi=200, sharex=True)



# parameters guess
p0 = [5.0, 2.0, 100]

colors_dict = dict()
colors_dict[0.0] = 'tab:blue'
colors_dict[8.0] = 'tab:orange'
colors_dict[15.6] = 'tab:green'

for ax, (P, (w, Sqw)) in zip(axes, data_dict.items()):


    # only use frequencies upto 12 meV
    mask = w <= xlim[1]

    # fit to DHO
    params, _ = curve_fit(psd_position_dho, w[mask], Sqw[mask], p0=p0)
    w0, gamma, A = params

    # print parameters
    print(f'P={P:4.1f}GPa : w0={w0:.2f} meV, gamma={gamma:.2f} meV')

    ax.text(0.1, 0.5, rf'$\omega_0$ = {w0:.2f} meV', transform=ax.transAxes, fontsize=8)
    ax.text(0.1, 0.4, rf'$\Gamma$ = {gamma:.2f} meV', transform=ax.transAxes, fontsize=8)


    # plot
    w_lin = np.linspace(*xlim, 100000)
    S_fit = psd_position_dho(w_lin, *params)
    ax.plot(w, Sqw, '-', lw=4.0, alpha=0.25, color=colors_dict[P], label=f'Data P={P} GPa')
    ax.plot(w_lin, S_fit, '--', lw=1.0, color=colors_dict[P], label=f'Fit')

    ax.set_xlim(xlim)
    ax.set_ylim(bottom=0.0)
    ax.legend(loc='best', frameon=False)
    ax.set_ylabel(r'$S(\mathbf{q}, \omega)$')

    # annotate roughly FWHM Gamma
    if P == 8.0:
        w_peak = np.sqrt(w0**2 - gamma**2 / 2.0)
        ax.annotate('', xy=(w_peak - gamma/2 , S_fit.max() / 2.0), xytext=(w_peak + gamma/2, S_fit.max() / 2.0), arrowprops=dict(arrowstyle='<->'))
        ax.text(w_peak-0.18, S_fit.max()/2 - 35, r'$\Gamma$')

axes[-1].set_xlabel('Frequency (meV)')
fig.tight_layout()
fig.subplots_adjust(hspace=0.0)
P= 0.0GPa : w0=8.99 meV, gamma=0.82 meV
P= 8.0GPa : w0=6.18 meV, gamma=1.48 meV
P=15.6GPa : w0=1.66 meV, gamma=6.17 meV
../_images/tutorials_dho_peak_fitting_17_1.png

For P = 15.6 GPa we obtain \(\Gamma > 2 \omega_0\), meaning that the mode is overdamped.