11. Ro-vibrational Spectra of Methane

To run this tutorial, install the optional dependency by uv sync --extra pyscf.

So far, we have mainly used local coordinates such as bonds, angles and dihedrals, each of which is defined by a few atoms. However, it is often desirable to move many atoms collectively in a certain direction. Such collective coordinates can reduce the number of essential DOFs, and are natural for vibrational analysis.

PySCIVR provides the displacement along a normal mode measured in the body frame. Since this coordinate is invariant under translations and rotations, it can be combined with the rotational DOFs to describe ro-vibrational motion. Only the mode vector needs to be specified: the dynamics is propagated in the Cartesian coordinates, and no construction of the complicated rovibrational Hamiltonian (Coriolis coupling, coordinate-dependent inertia tensor, etc.) is required.

In this tutorial, the rovibrational spectrum of methane is computed with the symmetric stretching mode and the overall rotation.

Redundant space

Potential

Integrator

Physical space

Cartesian (15 DOFs) with 11 constraints (3 translations, 8 other normal modes)

1D spline along the symmetric stretching mode (B3LYP/def2-TZVP)

ShakeRattle

Euler angles (3 DOFs) and normal coordinate (1 DOF)

  • Normal mode coordinate: The symmetric stretching mode (\(A_1\), harmonic frequency of 3032 \(\textrm{cm}^{-1}\)) is active, and the other eight normal modes are fixed by the constraints. The rotation is not constrained.

  • Potential: The potential could be computed by ab initio calculations on the fly. To accelerate the calculation, however, we construct a rotationally invariant Cartesian potential from a one-dimensional scan along the mode.

  • Initial states: Coherent states centered at the equilibrium structure with the rotational kinetic energies of 50, 200 and 500 \(\textrm{cm}^{-1}\).

  • Sampling: The initial positions are fixed at the equilibrium structure, only the momenta are sampled, including the vibrational momenta (see below).

  • Reference: The levels are not compared with exact quantum results. Instead, we check that the rotational levels ride on each vibrational level, and that their spacings agree with those of the rigid rotor, since the rovibrational coupling is small at low energies.

# import necessary libraries

import numpy as np
import matplotlib.pyplot as plt
from scipy.interpolate import CubicSpline
from scipy.optimize import brentq
import time

from pyscf import gto, dft
from pyscf.geomopt import berny_solver
from pyscf.hessian import thermo

from pyscivr.basis import get_overlap
from pyscivr.coordinates import InternalCoordinates
from pyscivr.hessian import eval_hess_active, interpolate_hessian, projected_hessian
from pyscivr.integrators import ShakeRattle
from pyscivr.potentials import DFTPotential, Calculator
from pyscivr.scivr import get_action, get_lagrangian, get_prefactor, get_ta_spectrum_fft
from pyscivr.units import CmInv, FemtoSec, HBar
from pyscivr.utils import get_geom, get_inertia, get_mass_center
from pyscivr.variations import make_vari_propagator, propagate_variation, make_jacobi_func, to_internal

Setup

The equilibrium structure used in this notebook was obtained by the geometry optimization with PySCF at the B3LYP/def2-TZVP level.

basis = "def2-tzvp"
xc = "b3lyp"

xyzstr_init = """
C        -0.000000000000       0.000000000000      -0.000000000000
H         0.627615334142       0.627615334142       0.627615334142
H        -0.627615334142      -0.627615334142       0.627615334142
H         0.627615334142      -0.627615334142      -0.627615334142
H        -0.627615334142       0.627615334142      -0.627615334142
"""

conv_params = {
    'gradientmax': 0.45e-5,
    'gradientrms': 0.15e-5,
    'stepmax': 1.8e-5,
    'steprms': 1.2e-5,
}

symbols, posi, mass = get_geom(xyzstr_init)
mol = gto.Mole(verbose=1)
mol.atom = [[s, p] for s, p in zip(symbols, posi)]
mol.basis = basis
mf = dft.RKS(mol, xc=xc)
mol_eq = berny_solver.optimize(mf, maxsteps=100, **conv_params)
XYZSTR = mol_eq.tostring()

assert np.allclose(mol_eq.atom_mass_list(isotope_avg=True), mass)

print(XYZSTR)

mf_eq = dft.RKS(mol_eq, xc=xc)
mf_eq.conv_tol = 1e-12
mf_eq.grids.level = 5
mf_eq.kernel()

hess = mf_eq.Hessian().kernel()   # (natm, natm, 3, 3) in Hartree/Bohr^2
natm = mol_eq.natm
hess_cart = hess.transpose(0, 2, 1, 3).reshape(3 * natm, 3 * natm)

# harmonic analysis (frequencies in cm^-1, normal modes)
res = thermo.harmonic_analysis(mol_eq, hess)
print('wavenumbers:', res["freq_wavenumber"])
omegas = res["freq_wavenumber"] * CmInv / HBar

norm_mode = res["norm_mode"].reshape(-1, 3 * natm)  # (nmode, 3*natm)
mode_vectors = norm_mode * np.repeat(np.sqrt(mass), 3).reshape(-1, 3 * natm)

# check orthonormality of mode vectors
assert np.allclose(np.dot(mode_vectors, mode_vectors.T), np.eye(len(mode_vectors)))
Warning: <pyscf.gto.mole.Mole object at 0x14fd4aa50> must be initialized before calling SCF.
Initialize <pyscf.gto.mole.Mole object at 0x14fd4aa50> in <pyscf.dft.rks.RKS object at 0x14fd4aba0>
C           0.00000000       -0.00000000       -0.00000000
H           0.62886702        0.62886702        0.62886702
H          -0.62886702       -0.62886702        0.62886702
H           0.62886702       -0.62886702       -0.62886702
H          -0.62886702        0.62886702       -0.62886702
wavenumbers: [1340.714 1340.714 1340.714 1556.308 1556.308 3031.904 3134.33  3134.33  3134.334]

Normal mode coordinates

The normal coordinate is defined in the body frame obtained by Horn’s method,

\[\begin{align*} z(x) = v \cdot \left[ \sqrt{m} \left( x_c C(x) - x_{\textrm{ref},c} \right) \right], \end{align*}\]

where \(x_c\) and \(x_{\textrm{ref},c}\) are the mass-centered current and reference structures, and \(C(x)\) is the rotation matrix that best superimposes the reference structure onto the current one. Since the current structure is rotated back to the orientation of the reference structure before the projection, \(z\) is invariant under translations and rotations.

The mode vector \(v\) is added by add_normal_mode. Note that \(v\) must be the normalized eigenvector of the mass-weighted Hessian.

SYMBOLS, POSI_REF, MASS = get_geom(XYZSTR)

assert np.allclose(get_mass_center(POSI_REF, MASS), 0.0) # centered at the origin
INERTIA_TENSOR = get_inertia(POSI_REF, MASS)
assert np.allclose(INERTIA_TENSOR, np.diag(np.diag(INERTIA_TENSOR))) # principal axis frame
INERTIA = np.diag(INERTIA_TENSOR)

CALC = DFTPotential(symbols, basis="def2-tzvp", xc="b3lyp")
ENER_REF = CALC.energy(POSI_REF)
print("Reference energy:", ENER_REF)

COORD_ALL = InternalCoordinates(POSI_REF, MASS)

COORD_ALL.add_translation()
COORD_ALL.add_euler()
for i in range(len(mode_vectors)):
    COORD_ALL.add_normal_mode(mode_vectors[i], name=f"mode_{i}")
COORD_ALL.build()

print(COORD_ALL)

MODE_IDX = 5 # mode 5 : symmetric stretch mode of methane (3032 cm^-1)
MODE_VECTOR = mode_vectors[MODE_IDX]
OMEGA = omegas[MODE_IDX]

COORD_CONST = InternalCoordinates(POSI_REF, MASS)

COORD_CONST.add_translation()
for i in range(len(mode_vectors)):
    if i != MODE_IDX:
        COORD_CONST.add_normal_mode(mode_vectors[i], name=f"mode_{i}")
COORD_CONST.build()

print(COORD_CONST)

ACTIVE_IDX = np.array([3,4,5, COORD_ALL.index[f"mode_{MODE_IDX}"]]) # 3 euler angles + 1 selected normal mode
TOPOLOGIES = [COORD_ALL.topology[i] for i in ACTIVE_IDX]

JACOBI_FUNC, JACOBI_REV_FUNC, INTERNAL_FUNC, MONODROMY_FUNC = make_jacobi_func(COORD_ALL, ACTIVE_IDX)
Reference energy: -1103.0823521843631
Build complete: 15 coordinates
InternalCoordinates:
    0: translation_x   value_ref=0.000000
    1: translation_y   value_ref=0.000000
    2: translation_z   value_ref=0.000000
    3: euler_yaw       value_ref=0.000000
    4: euler_pitch     value_ref=1.570796
    5: euler_roll      value_ref=0.000000
    6: mode_0          value_ref=0.000000
    7: mode_1          value_ref=0.000000
    8: mode_2          value_ref=0.000000
    9: mode_3          value_ref=0.000000
   10: mode_4          value_ref=0.000000
   11: mode_5          value_ref=0.000000
   12: mode_6          value_ref=0.000000
   13: mode_7          value_ref=0.000000
   14: mode_8          value_ref=0.000000
Build complete: 11 coordinates
InternalCoordinates:
    0: translation_x   value_ref=0.000000
    1: translation_y   value_ref=0.000000
    2: translation_z   value_ref=0.000000
    3: mode_0          value_ref=0.000000
    4: mode_1          value_ref=0.000000
    5: mode_2          value_ref=0.000000
    6: mode_3          value_ref=0.000000
    7: mode_4          value_ref=0.000000
    8: mode_6          value_ref=0.000000
    9: mode_7          value_ref=0.000000
   10: mode_8          value_ref=0.000000

Potential

Since the displacement along the mode is explicitly given by \(x(z) = x_{\textrm{ref}} + z v / \sqrt{m}\), no Newton iteration is needed for the scan. The range of \(z\) is determined from the classical turning points at 10000 \(\textrm{cm}^{-1}\), which are found by the root finding along the mode.

The potential is constructed as \(V(z(x))\), which is invariant under translations and rotations. On the configurations displaced along the mode up to rotations, which are the only configurations reached under the constraints, the gradient is exactly obtained by rotating \(V'(z) \sqrt{m_i} v_i\) with \(C(x)\). The Hessian is not implemented, since eval_hess_active requires only the gradient.

def find_turning_points(mode_vector, omega, ener_target, factor=1.5):
    """Find the turning points along a normal mode vector for a given target energy.

    Args:
        mode_vector: ``(3N,)`` normalized eigenvector of the mass-weighted Hessian.
        omega: Harmonic angular frequency of the mode (internal units), used for the initial guess.
        ener_target: Target energy above the reference energy (in cm^-1).
        factor: Initial bracket relative to the harmonic turning point.
    Returns:
        Tuple of the positive and negative turning points.
    """
    disp = (mode_vector / np.repeat(np.sqrt(MASS), 3)).reshape(-1, 3)
    z_harm = np.sqrt(2 * ener_target * CmInv) / omega # harmonic turning point

    def f(z):
        return float(CALC.energy(POSI_REF + z * disp)) - ENER_REF - ener_target * CmInv

    def _find_turning_point(sign):
        z_hi = sign * factor * z_harm
        while f(z_hi) < 0: # expand until the bracket contains the root
            z_hi *= 1.2
        return brentq(f, min(0.0, z_hi), max(0.0, z_hi), xtol=0.01 * z_harm)

    return _find_turning_point(+1), _find_turning_point(-1)

z_plus, z_minus = find_turning_points(MODE_VECTOR, OMEGA, ener_target=10000)

grid = np.linspace(z_minus, z_plus, 20)
posi_scan = POSI_REF + grid[:, None, None] * (MODE_VECTOR / np.repeat(np.sqrt(MASS), 3)).reshape(-1, 3)
pot = CALC.energy(posi_scan)
pot_spline = CubicSpline(grid, pot)
x = np.linspace(z_minus, z_plus, 10000)

plt.plot(grid, (pot - ENER_REF) / CmInv, 'o', label='data')
plt.plot(x, (pot_spline(x) - ENER_REF) / CmInv, '-', label='spline')
plt.xlabel('Displacement (a.u.)')
plt.ylabel('Potential Energy (cm^-1)')
plt.legend()
plt.show()
../_images/9b76b574636ddf74e6d266f2387db1705579e917ebb31859ab66625bfaf60e51.png
class NormalModeSplinePotential(Calculator):
    r"""Potential depending only on one normal coordinate, interpolated by a cubic spline.

    The normal coordinate is evaluated in the body frame obtained by Horn's method,

    .. math::

        z(x) = v \cdot \left[ \sqrt{m} \left( x_c C(x) - x_{\text{ref},c} \right) \right],

    so that the potential :math:`V(z(x))` is invariant under translations and rotations.
    The potential is intended for the configurations displaced along the mode
    up to translations and rotations, where the other normal coordinates are fixed
    by the constraints. In this case, the gradient is obtained by rotating
    :math:`V'(z) \sqrt{m_i} v_i` with :math:`C`.

    Warning:
        The spline is not reliable outside the scanned range of :math:`z`.
    """

    def __init__(self, pot_spline, posi_ref, mass, mode_vector) -> None:
        mass = np.asarray(mass, dtype=float)
        posi_ref = np.asarray(posi_ref, dtype=float)
        ref_c = posi_ref - (mass @ posi_ref) / mass.sum()
        n_atoms = len(mass)
        sv = np.sqrt(mass)[:, None] * np.asarray(mode_vector, dtype=float).reshape(n_atoms, 3)  # (N, 3)

        def mass_centered(x):  # (B, N, 3)
            return x - np.einsum("i,bij->bj", mass, x)[:, None, :] / mass.sum()

        def quat_to_rotmat(q):  # (B, 4) -> (B, 3, 3)
            q0, q1, q2, q3 = q[:, 0], q[:, 1], q[:, 2], q[:, 3]
            C = np.stack(
                [
                    np.stack([1.0 - 2.0 * (q2**2 + q3**2), 2.0 * (q1 * q2 - q0 * q3), 2.0 * (q1 * q3 + q0 * q2)], axis=-1),
                    np.stack([2.0 * (q1 * q2 + q0 * q3), 1.0 - 2.0 * (q1**2 + q3**2), 2.0 * (q2 * q3 - q0 * q1)], axis=-1),
                    np.stack([2.0 * (q1 * q3 - q0 * q2), 2.0 * (q2 * q3 + q0 * q1), 1.0 - 2.0 * (q1**2 + q2**2)], axis=-1),
                ],
                axis=1,
            )
            return C

        def get_rotation_matrix(x_c):  # (B, N, 3) -> (B, 3, 3)
            A = np.einsum("i,ij,bik->bjk", mass, ref_c, x_c)
            tr = np.trace(A, axis1=1, axis2=2)
            D = A - np.transpose(A, (0, 2, 1))
            delta = np.stack([D[:, 1, 2], D[:, 2, 0], D[:, 0, 1]], axis=-1)
            K = np.zeros((len(x_c), 4, 4))
            K[:, 0, 0] = tr
            K[:, 0, 1:] = delta
            K[:, 1:, 0] = delta
            K[:, 1:, 1:] = A + np.transpose(A, (0, 2, 1)) - tr[:, None, None] * np.eye(3)
            return quat_to_rotmat(np.linalg.eigh(K)[1][:, :, -1])

        def get_normal_coord(x):  # (B, N, 3) -> (B,), (B, 3, 3)
            x_c = mass_centered(x)
            C = get_rotation_matrix(x_c)
            z = np.einsum("ij,bij->b", sv, x_c @ C - ref_c)
            return z, C

        self.pot_spline = pot_spline  # scipy CubicSpline (derivatives by pot_spline(z, 1))
        self.sv = sv
        self.get_normal_coord = get_normal_coord

    def _energy_forces_batch(self, x):
        z, C = self.get_normal_coord(x)
        ener = self.pot_spline(z)
        grad = self.pot_spline(z, 1)[:, None, None] * (self.sv[None] @ np.transpose(C, (0, 2, 1)))
        return ener, -grad

CALC_SPLINE = NormalModeSplinePotential(pot_spline, POSI_REF, MASS, MODE_VECTOR)

def test_spline():
    x = posi_scan # (S, N, 3), on the scanned line
    R = np.linalg.qr(np.random.randn(3, 3))[0] # random rotation matrix
    if np.linalg.det(R) < 0:
        R[:, 0] *= -1 # make it a proper rotation
    xr = np.einsum("ij,bkj->bki", R, x) + np.random.randn(3) # rotated and translated
    e, f = CALC_SPLINE.energy_forces(x)
    er, fr = CALC_SPLINE.energy_forces(xr)

    # 1. z reproduces the scan grid (normalization and sign of mode_vector, Horn frame)
    z, _ = CALC_SPLINE.get_normal_coord(x)

    assert np.allclose(z, grid)

    # 2. invariance of energy and covariance of forces: F(Rx + t) = R F(x)
    assert np.allclose(e, er)
    assert np.allclose(fr, np.einsum("ij,bkj->bki", R, f))

    # 3. forces match the finite difference of the energy along the mode
    disp = (MODE_VECTOR.reshape(-1, 3) / np.sqrt(MASS)[:, None]) # dx/dz
    h = 1e-5
    ep, _ = CALC_SPLINE.energy_forces(x + h * disp)
    em, _ = CALC_SPLINE.energy_forces(x - h * disp)
    assert np.allclose((ep - em) / (2 * h), -np.einsum("bij,ij->b", f, disp))

test_spline()

Calculation of spectra

run computes the spectra for all the initial states by the following steps for each trajectory:

  1. Propagate the trajectory by ShakeRattle.

  2. Propagate the variation and compute the monodromy matrix in the physical space. The projected Hessian is evaluated with eval_hess_active.

  3. Exclude the steps where the symplectic condition is not satisfied.

  4. Compute the action, the prefactor and the overlap with each initial state, and the spectrum by the FFT.

The spectra are averaged over the trajectories and saved for each initial state. Note that each trajectory is used for all the initial states, so that the cost of the dynamics and the monodromy matrix is shared.

def get_rot_momenta(K_rot, L_dir):
    L_dir /= np.linalg.norm(L_dir, axis=1, keepdims=True)
    scale = np.sqrt(2.0 * K_rot * CmInv / np.sum(L_dir**2 / INERTIA, axis=1))
    omega = scale[:, None] * L_dir / INERTIA # (S, 3)
    return MASS[None, :, None] * np.cross(omega[:, None, :], POSI_REF[None]) # (S, N, 3)

def sample_momenta(n_sample, K_rot_range, K_vib_range, seed=10202):
    rng = np.random.default_rng(seed)

    # rotation: uniform direction, |L| from K_rot = L^T I^{-1} L / 2
    L_dir = rng.normal(size=(n_sample, 3))
    
    K_rot = rng.uniform(*K_rot_range, size=n_sample)
    p_rot = get_rot_momenta(K_rot, L_dir)

    # vibration: along the normal mode with random sign
    K_vib = rng.uniform(*K_vib_range, size=n_sample)
    P_vib = rng.choice([-1.0, 1.0], size=n_sample) * np.sqrt(2.0 * K_vib * CmInv)
    sv = np.sqrt(MASS)[:, None] * MODE_VECTOR.reshape(-1, 3) # (N, 3)
    p_vib = P_vib[:, None, None] * sv[None]

    return p_rot + p_vib, K_rot, K_vib

def run(posi_sample_list, mome_sample_list, K_rot_list, K_vib_list,
        posi_ref_list, mome_ref_list, gamma_ref_list, fpath_list,
        dt, n_step, n_fft, symp_err_tol=1e-4, B_cond_thresh=1e3):

    # Judge function to check if the point is singular or not
    judge_func= lambda symp_err : np.isfinite(symp_err) & (symp_err < symp_err_tol)

    # Reference positions and momenta in the internal coordinates
    posi_ref_int, mome_ref_int = zip(*[INTERNAL_FUNC(p, q) for p, q in zip(posi_ref_list, mome_ref_list)])

    n_sample = len(posi_sample_list)
    n_ref = len(posi_ref_list)
    spectra = [0.0] * n_ref
    vari_propagator = make_vari_propagator(COORD_CONST, dt)

    t_start = time.perf_counter()
    for i in range(n_sample):
        # Get trajectory
        dyn = ShakeRattle(CALC_SPLINE, posi_sample_list[i], mome_sample_list[i], MASS, dt, COORD_CONST)
        traj = dyn.run(n_step)
        traj_posi, traj_mome = traj["posi"], traj["mome"]

        # get variation
        cond_B = np.linalg.cond(np.asarray(COORD_ALL.get_const_grad_batch(traj_posi)))
        valid_steps = np.arange(len(traj_posi))[cond_B < B_cond_thresh][::10]
        
        HA, AA, BA = eval_hess_active(CALC_SPLINE, COORD_ALL, ACTIVE_IDX, traj_posi, valid_steps, chunk_size=len(traj_posi))
        hess = interpolate_hessian(valid_steps, projected_hessian(HA, AA, BA), len(traj_posi))
        J_rev_init = JACOBI_REV_FUNC(traj_posi[0], traj_mome[0])

        vari = propagate_variation(vari_propagator, traj_posi, traj_mome, traj["lam"], traj["mu"], hess, vari_init=J_rev_init)
        posi_int, mome_int, mono_int, symplectic_error = to_internal(MONODROMY_FUNC, traj_posi, traj_mome, vari)

        # points with large symplectic error are treated as singularities
        valid = judge_func(symplectic_error)

        ener_grid = 2.0 * np.pi * HBar * np.arange(n_fft) / (dt * n_fft)
        lag = get_lagrangian(traj["total_energy"], traj["potential_energy"], ener_ref=ENER_REF)
        action = get_action(lag, dt)

        for j in range(n_ref):
            posi_center, mome_center = posi_ref_int[j], mome_ref_int[j]
            gamma = gamma_ref_list[j]
            prefactor = get_prefactor(mono_int, gamma, valid=valid)
            overlap = get_overlap(TOPOLOGIES, posi_center, mome_center, posi_int, mome_int, gamma)
            _, spec = get_ta_spectrum_fft(prefactor, overlap, action, dt, n_fft, valid=valid)
            spectra[j] += spec

        if i % 10 == 0:
            print(f"{i+1:6d} / {n_sample}, K_rot = {K_rot_list[i]:.1f}, K_vib = {K_vib_list[i]:.1f}, "
                f"max symp error = {np.max(np.abs(symplectic_error)):.3e}, ({time.perf_counter() - t_start:.1f} sec)", flush=True)

    for j in range(n_ref):
        spectra[j] /= n_sample

    for j in range(n_ref):
        np.savez(fpath_list[j], ener_grid=ener_grid, spectrum=spectra[j])

Execute calculation

The total propagation time is 1200 fs.

if __name__ == "__main__":
    # set parameters
    n_sample = 1000
    dt = 0.3 * FemtoSec
    n_step = 4000
    n_fft = 2**19

    # set reference state
    K_rot_ref_list = [50.0, 200.0, 500.0]
    L_dir_ref = np.array([1.0, -1.0, -1.0])
    K_vib_ref = OMEGA * HBar / CmInv

    # no vibrational kinetic energy for the reference state to target the vibrational ground state (v=0)
    mome_ref_list = np.array([get_rot_momenta(k_rot, [L_dir_ref]) for k_rot in K_rot_ref_list])
    mome_vib_ref = np.sqrt(2.0 * K_vib_ref * CmInv) * np.sqrt(MASS)[:, None] * MODE_VECTOR.reshape(-1, 3)
    mome_ref_list += mome_vib_ref[None, :, :]
    posi_ref_list = np.asarray([POSI_REF] * len(K_rot_ref_list))

    # [gamma_rot, gamma_rot, gamma_rot, gamma_vib]
    gamma_ref = [1.0 / (2 * 0.2**2)] * 3 + [OMEGA / (2.0 * HBar)] 
    gamma_ref_list = np.asarray([gamma_ref] * len(K_rot_ref_list))
    fpath_list = [f"methane_rovib_{k_rot:.0f}_n_{n_sample}_spectra.npz" for k_rot in K_rot_ref_list]

    K_rot_range = (10.0, 1000.0)
    K_vib_range = (0.0, OMEGA * HBar / CmInv)
    mome_sample_list, K_rot_list, K_vib_list = sample_momenta(n_sample, K_rot_range, K_vib_range)
    posi_sample_list = np.asarray([POSI_REF] * n_sample)

    run(posi_sample_list, mome_sample_list, K_rot_list, K_vib_list,
        posi_ref_list, mome_ref_list, gamma_ref_list, fpath_list,
        dt, n_step, n_fft)
     1 / 1000, K_rot = 153.9, K_vib = 2801.1, max symp error = 1.077e-06, (3.5 sec)
    11 / 1000, K_rot = 266.9, K_vib = 1224.2, max symp error = 2.777e-09, (16.1 sec)
    21 / 1000, K_rot = 22.6, K_vib = 1735.3, max symp error = 1.265e-08, (29.0 sec)
    31 / 1000, K_rot = 188.3, K_vib = 760.7, max symp error = 1.930e-09, (41.7 sec)
    41 / 1000, K_rot = 19.3, K_vib = 11.7, max symp error = 1.848e-10, (54.9 sec)
    51 / 1000, K_rot = 697.2, K_vib = 309.6, max symp error = 3.119e-10, (67.8 sec)
    61 / 1000, K_rot = 227.3, K_vib = 183.8, max symp error = 1.747e-10, (80.8 sec)
    71 / 1000, K_rot = 391.9, K_vib = 1812.1, max symp error = 2.647e-08, (93.5 sec)
    81 / 1000, K_rot = 89.4, K_vib = 784.3, max symp error = 1.270e-09, (105.9 sec)
    91 / 1000, K_rot = 956.7, K_vib = 2187.9, max symp error = 1.229e-06, (118.7 sec)
   101 / 1000, K_rot = 730.5, K_vib = 1520.3, max symp error = 1.362e-08, (131.5 sec)
   111 / 1000, K_rot = 838.0, K_vib = 461.3, max symp error = 6.278e-10, (144.1 sec)
   121 / 1000, K_rot = 599.4, K_vib = 1751.6, max symp error = 2.194e-08, (156.7 sec)
   131 / 1000, K_rot = 674.9, K_vib = 641.4, max symp error = 8.945e-10, (169.4 sec)
   141 / 1000, K_rot = 743.0, K_vib = 1242.1, max symp error = 5.213e-09, (182.3 sec)
   151 / 1000, K_rot = 848.7, K_vib = 1511.9, max symp error = 2.455e-08, (195.0 sec)
   161 / 1000, K_rot = 134.2, K_vib = 2143.2, max symp error = 4.494e-08, (208.0 sec)
   171 / 1000, K_rot = 873.0, K_vib = 3028.3, max symp error = 7.703e-06, (220.6 sec)
   181 / 1000, K_rot = 462.9, K_vib = 2206.9, max symp error = 2.890e-07, (232.9 sec)
   191 / 1000, K_rot = 930.4, K_vib = 3019.2, max symp error = 7.278e-06, (245.5 sec)
   201 / 1000, K_rot = 746.6, K_vib = 2736.7, max symp error = 4.220e-06, (258.3 sec)
   211 / 1000, K_rot = 340.9, K_vib = 2797.7, max symp error = 1.738e-06, (271.1 sec)
   221 / 1000, K_rot = 236.4, K_vib = 229.9, max symp error = 3.006e-10, (283.9 sec)
   231 / 1000, K_rot = 97.1, K_vib = 2685.5, max symp error = 3.968e-07, (296.7 sec)
   241 / 1000, K_rot = 766.7, K_vib = 2652.3, max symp error = 2.076e-06, (309.8 sec)
   251 / 1000, K_rot = 881.7, K_vib = 2660.7, max symp error = 4.149e-06, (322.7 sec)
   261 / 1000, K_rot = 783.6, K_vib = 1702.8, max symp error = 1.217e-08, (335.6 sec)
   271 / 1000, K_rot = 425.1, K_vib = 618.9, max symp error = 8.167e-10, (348.5 sec)
   281 / 1000, K_rot = 579.9, K_vib = 1826.9, max symp error = 5.449e-08, (361.3 sec)
   291 / 1000, K_rot = 904.0, K_vib = 1586.8, max symp error = 2.194e-08, (373.8 sec)
   301 / 1000, K_rot = 643.7, K_vib = 2002.1, max symp error = 8.421e-08, (386.4 sec)
   311 / 1000, K_rot = 199.4, K_vib = 1756.4, max symp error = 1.235e-08, (398.9 sec)
   321 / 1000, K_rot = 844.4, K_vib = 2544.2, max symp error = 2.186e-06, (411.6 sec)
   331 / 1000, K_rot = 141.3, K_vib = 983.2, max symp error = 2.128e-09, (424.1 sec)
   341 / 1000, K_rot = 329.7, K_vib = 155.7, max symp error = 1.414e-10, (436.6 sec)
   351 / 1000, K_rot = 366.1, K_vib = 2927.1, max symp error = 5.508e-06, (449.2 sec)
   361 / 1000, K_rot = 671.4, K_vib = 22.1, max symp error = 1.457e-10, (461.8 sec)
   371 / 1000, K_rot = 993.3, K_vib = 2624.2, max symp error = 2.771e-06, (474.3 sec)
   381 / 1000, K_rot = 960.1, K_vib = 1853.0, max symp error = 1.258e-07, (486.7 sec)
   391 / 1000, K_rot = 202.7, K_vib = 963.7, max symp error = 1.750e-09, (499.2 sec)
   401 / 1000, K_rot = 917.0, K_vib = 2431.6, max symp error = 2.519e-06, (512.2 sec)
   411 / 1000, K_rot = 507.1, K_vib = 1933.2, max symp error = 5.582e-08, (525.3 sec)
   421 / 1000, K_rot = 637.4, K_vib = 1985.6, max symp error = 9.803e-08, (538.0 sec)
   431 / 1000, K_rot = 821.5, K_vib = 2658.3, max symp error = 2.125e-06, (550.6 sec)
   441 / 1000, K_rot = 585.1, K_vib = 1357.8, max symp error = 1.938e-07, (562.6 sec)
   451 / 1000, K_rot = 856.7, K_vib = 1512.7, max symp error = 1.301e-08, (575.3 sec)
   461 / 1000, K_rot = 846.7, K_vib = 1718.8, max symp error = 1.365e-07, (587.9 sec)
   471 / 1000, K_rot = 882.2, K_vib = 2697.1, max symp error = 2.580e-06, (600.7 sec)
   481 / 1000, K_rot = 445.1, K_vib = 2087.6, max symp error = 6.003e-08, (613.2 sec)
   491 / 1000, K_rot = 524.6, K_vib = 95.8, max symp error = 2.612e-10, (625.0 sec)
   501 / 1000, K_rot = 950.1, K_vib = 1944.0, max symp error = 3.712e-07, (636.9 sec)
   511 / 1000, K_rot = 780.5, K_vib = 2153.4, max symp error = 6.011e-07, (648.7 sec)
   521 / 1000, K_rot = 788.7, K_vib = 1166.3, max symp error = 2.564e-09, (660.6 sec)
   531 / 1000, K_rot = 438.7, K_vib = 2296.4, max symp error = 4.067e-07, (673.2 sec)
   541 / 1000, K_rot = 750.2, K_vib = 2654.7, max symp error = 3.318e-06, (685.4 sec)
   551 / 1000, K_rot = 759.6, K_vib = 2364.1, max symp error = 8.376e-07, (697.4 sec)
   561 / 1000, K_rot = 586.3, K_vib = 633.9, max symp error = 1.413e-09, (709.3 sec)
   571 / 1000, K_rot = 384.6, K_vib = 1066.1, max symp error = 1.244e-09, (721.6 sec)
   581 / 1000, K_rot = 1000.0, K_vib = 2773.2, max symp error = 4.394e-06, (733.5 sec)
   591 / 1000, K_rot = 546.2, K_vib = 1562.4, max symp error = 7.499e-09, (745.4 sec)
   601 / 1000, K_rot = 451.1, K_vib = 473.9, max symp error = 6.164e-10, (757.2 sec)
   611 / 1000, K_rot = 872.9, K_vib = 884.1, max symp error = 4.320e-09, (769.4 sec)
   621 / 1000, K_rot = 472.7, K_vib = 1305.2, max symp error = 2.382e-09, (781.1 sec)
   631 / 1000, K_rot = 919.2, K_vib = 535.4, max symp error = 4.438e-10, (792.9 sec)
   641 / 1000, K_rot = 575.7, K_vib = 1695.8, max symp error = 2.010e-08, (804.7 sec)
   651 / 1000, K_rot = 472.6, K_vib = 1143.6, max symp error = 1.990e-09, (816.6 sec)
   661 / 1000, K_rot = 386.9, K_vib = 562.1, max symp error = 1.094e-09, (829.3 sec)
   671 / 1000, K_rot = 113.8, K_vib = 897.8, max symp error = 1.103e-09, (841.4 sec)
   681 / 1000, K_rot = 966.1, K_vib = 2802.2, max symp error = 3.039e-06, (854.2 sec)
   691 / 1000, K_rot = 821.1, K_vib = 1955.2, max symp error = 3.133e-07, (866.3 sec)
   701 / 1000, K_rot = 878.9, K_vib = 1831.7, max symp error = 9.151e-08, (878.3 sec)
   711 / 1000, K_rot = 151.1, K_vib = 621.1, max symp error = 8.095e-10, (890.2 sec)
   721 / 1000, K_rot = 649.7, K_vib = 1038.5, max symp error = 1.306e-09, (902.1 sec)
   731 / 1000, K_rot = 644.3, K_vib = 620.9, max symp error = 6.622e-10, (914.1 sec)
   741 / 1000, K_rot = 926.5, K_vib = 454.1, max symp error = 8.813e-10, (926.6 sec)
   751 / 1000, K_rot = 754.2, K_vib = 701.7, max symp error = 2.816e-10, (938.7 sec)
   761 / 1000, K_rot = 49.1, K_vib = 2753.2, max symp error = 3.938e-07, (950.7 sec)
   771 / 1000, K_rot = 795.0, K_vib = 2872.0, max symp error = 6.948e-06, (962.7 sec)
   781 / 1000, K_rot = 284.9, K_vib = 3012.9, max symp error = 8.338e-06, (974.9 sec)
   791 / 1000, K_rot = 980.8, K_vib = 542.2, max symp error = 5.153e-10, (987.8 sec)
   801 / 1000, K_rot = 928.5, K_vib = 304.1, max symp error = 5.042e-10, (999.7 sec)
   811 / 1000, K_rot = 148.8, K_vib = 1576.8, max symp error = 6.607e-09, (1011.6 sec)
   821 / 1000, K_rot = 247.3, K_vib = 2867.6, max symp error = 1.703e-06, (1023.8 sec)
   831 / 1000, K_rot = 662.3, K_vib = 1778.9, max symp error = 4.240e-08, (1036.5 sec)
   841 / 1000, K_rot = 685.5, K_vib = 422.7, max symp error = 3.557e-10, (1049.3 sec)
   851 / 1000, K_rot = 336.1, K_vib = 1308.1, max symp error = 1.294e-09, (1061.4 sec)
   861 / 1000, K_rot = 461.2, K_vib = 408.3, max symp error = 5.737e-10, (1073.5 sec)
   871 / 1000, K_rot = 981.6, K_vib = 1194.0, max symp error = 3.128e-09, (1085.5 sec)
   881 / 1000, K_rot = 935.2, K_vib = 672.0, max symp error = 7.755e-10, (1097.6 sec)
   891 / 1000, K_rot = 20.1, K_vib = 1102.3, max symp error = 2.925e-09, (1109.7 sec)
   901 / 1000, K_rot = 417.4, K_vib = 1794.0, max symp error = 2.476e-08, (1122.3 sec)
   911 / 1000, K_rot = 700.6, K_vib = 1500.1, max symp error = 1.293e-08, (1134.7 sec)
   921 / 1000, K_rot = 555.3, K_vib = 423.7, max symp error = 8.365e-10, (1146.4 sec)
   931 / 1000, K_rot = 843.6, K_vib = 2910.0, max symp error = 2.015e-06, (1158.2 sec)
   941 / 1000, K_rot = 407.0, K_vib = 2962.5, max symp error = 1.112e-04, (1170.0 sec)
   951 / 1000, K_rot = 22.2, K_vib = 1343.5, max symp error = 3.624e-08, (1181.8 sec)
   961 / 1000, K_rot = 533.0, K_vib = 426.4, max symp error = 5.777e-10, (1193.6 sec)
   971 / 1000, K_rot = 347.8, K_vib = 62.2, max symp error = 2.182e-10, (1205.4 sec)
   981 / 1000, K_rot = 396.2, K_vib = 2013.6, max symp error = 7.903e-08, (1217.2 sec)
   991 / 1000, K_rot = 262.8, K_vib = 1398.7, max symp error = 4.277e-09, (1230.0 sec)
if __name__ == "__main__":
    import matplotlib.pyplot as plt
    from scipy.signal import find_peaks
 
    fig, ax = plt.subplots(3, 1, figsize=(7, 7), sharex=True)
    colors = ["red", "green", "blue"]

    for i, fpath in enumerate(fpath_list):
        data = np.load(fpath)
        ener_grid = data["ener_grid"] / CmInv
        spectrum = data["spectrum"]
        spectrum /= np.max(spectrum[ener_grid <= 6000])
        peaks, _ = find_peaks(spectrum, height=0.1, prominence=0.1)
        peaks = peaks[ener_grid[peaks] <= 6000]
        print(f"Peaks (cm^-1) for K = {K_rot_ref_list[i]:.0f}: {np.round(ener_grid[peaks], 1)}")
 
        ax[i].plot(ener_grid, spectrum, label=f"K = {K_rot_ref_list[i]:.0f}", color=colors[i])
        ax[i].plot(ener_grid[peaks], spectrum[peaks], "x", color="black")
        ax[i].set_ylim(0, 1.2)
        ax[i].legend()
 
    ax[-1].set_xlim(1000, 6000)
    ax[-1].set_xlabel("Energy (cm$^{-1}$)")
    ax[0].set_ylabel("Intensity (arb. unit)")
    plt.tight_layout()
    plt.show()
Peaks (cm^-1) for K = 50: [1510.8 1544.1 1574.9 1611.8 1669.4 1732.9 1809.2 1896.6 4560.2 4593.3 4630.9 4687.5 4750.3]
Peaks (cm^-1) for K = 200: [1573.  1610.3 1668.  1730.5 1807.5 1894.7 1986.5 2090.8 4591.6 4629.8 4686.6 4749.6 4824.7 4911.6 5004.5 5107.2]
Peaks (cm^-1) for K = 500: [1666.5 1728.4 1805.8 1893.2 1985.  2089.4 2209.4 2336.6 2472.8 4749.  4823.4 4910.6 5002.8 5105.9 5222.5 5348.1 5485.5]
../_images/b7122c2282486962f2e6e6a402fc93085b78a47920b036ae41b8f6d78d803403.png

The spectra consist of two bands: the vibrational ground state (around 1500-2500 \(\textrm{cm}^{-1}\)) and the fundamental of the symmetric stretching (around 4500-5500 \(\textrm{cm}^{-1}\)), on each of which the rotational levels ride. The energies are measured from the potential minimum. The peak positions are averaged over the initial states, and the band origin \(E_0\) is determined by fitting \(E_J = E_0 + B J(J+1)\) to each band.

\(v\) = 0 (\(E_0\) = 1509.7 \(\text{cm}^{-1}\))

\(J\)

Computed

\(B J(J+1)\)

Deviation

2

34.4

31.8

+2.6

3

64.3

63.7

+0.6

4

101.4

106.1

−4.7

5

158.3

159.2

−0.9

6

220.9

222.9

−2.0

7

297.8

297.2

+0.6

8

385.1

382.1

+3.0

9

476.1

477.6

−1.5

10

580.4

583.8

−3.4

11

699.7

700.5

−0.8

12

826.9

827.9

−1.0

13

963.1

965.9

−2.8

\(v\) = 1 (\(E_1\) = 4528.9 \(\text{cm}^{-1}\))

\(J\)

Computed

\(B J(J+1)\)

Deviation

2

31.3

31.8

−0.5

3

63.6

63.7

−0.1

4

101.5

106.1

−4.6

5

158.2

159.2

−1.0

6

220.7

222.9

−2.2

7

295.2

297.2

−2.0

8

382.2

382.1

+0.1

9

474.8

477.6

−2.8

10

577.7

583.8

−6.1

11

693.6

700.5

−6.9

12

819.2

827.9

−8.7

13

956.6

965.9

−9.3

  • Rotational levels: In the ground state, the computed levels agree with those of the rigid rotor (\(B = 5.307\) \(\textrm{cm}^{-1}\)) within a few \(\textrm{cm}^{-1}\) up to \(J = 13\). The levels of \(J = 0\) and \(1\) (about 10 \(\textrm{cm}^{-1}\) apart) are not resolved with the present propagation time. In the excited state, the deviation increases with \(J\), giving a smaller rotational constant.

  • Zero-point energy and fundamental: The band origins give the zero-point energy of 1510 \(\textrm{cm}^{-1}\) and the fundamental of 3019 \(\textrm{cm}^{-1}\), slightly lower than the harmonic values (1516 and 3032 \(\textrm{cm}^{-1}\)), consistent with the weak anharmonicity of the symmetric stretching.

  • Intensities: As the rotational kinetic energy of the initial state increases, the intensity shifts to higher \(J\).