Domain: Chemistry — Physical Chemistry

Problem:
Consider an intramolecular proton transfer approximated by a one-dimensional quantum particle (a proton) moving along coordinate \(x\) (in \[\mathring{A}\]). The 1D potential energy surface (PES) is an asymmetric double-well defined analytically by

\[V(x) = V_0\frac{(x^2 - a^2)^2}{a^4} + \Delta\,x\]

with the following parameters:

\[V_0 = 0.60\ \text{eV} \]

\[a = 0.9\ \mathring{A} \]

\[\Delta = 0.050 \mathrm{eV}\mathring{A}^{-1}\]


Physical constants (use these exact values):

\[m_p = 1.6726219\times10^{-27}\ \text{kg} \]

\[h = 6.62607015\times10^{-34} \text{Js}, \quad \hbar = \frac{h}{2\pi} \]

\[k_B = 1.380649\times10^{-23}\ \mathrm{J·K}^{-1} \]

\[1 \text{eV} = 1.602176634\times10^{-19} \text{J} \]

\[T = 300.000\ \text{K}\]

Domain \& discretization requirements (must be followed exactly):

\[x \in [-3.0, \;3.0] \mathring{A}\]

Use a uniform grid with at least \(2001\) points (i.e., grid spacing \(\Delta x \le 0.003\ \mathring{A}\)).
Apply Dirichlet boundary conditions (\(\psi(x) = 0\) at both edges).

Energy integration range:

\[E_{\min} = 0, \quad E_{\max} = 1.25 \times V_{\text{barrier}}\]

where \(V_{\text{barrier}}\) is the maximum of \(V(x)\).
Use at least \(4{,}000\) evenly spaced energy points.


**Computational task**

Build the 1D Hamiltonian

\[\hat{H} = -\frac{\hbar^2}{2m_p}\frac{d^2}{dx^2} + V(x)\]

using second-order finite-difference discretization.

Solve for eigenvalues and eigenvectors of \(\hat{H}\). Identify bound eigenstates localized in the left well (reactant) by selecting eigenstates whose expectation value \(\langle x\rangle < 0\). Keep bound states with \(E_n < V_{\text{saddle}}\) (barrier maximum).

Compute the reactant quantum partition function:

\[Q_r = \sum_{n \in \text{left-well}} e^{-\beta E_n}, \qquad \beta = \frac{1}{k_B T}\]

Include all states below the barrier to ensure convergence.

Energy-dependent transmission \(T(E)\)

For each energy \(E\) in the grid, compute the quantum transmission coefficient \(T(E)\) for a particle of mass \(m_p\) through \(V(x)\).
Use a high-accuracy scattering solver (Numerov integration or transfer-matrix method), with boundary conditions corresponding to an incoming wave from the left.

Ensure \(0 \le T(E) \le 1\).

Thermal rate constant (quantum flux-over-population formula)

FInally compute the thermal rate constant:

\[k(T) = \frac{1}{2\pi\hbar\, Q_r} \int_0^{E_{\max}} T(E)\, e^{-\beta E}\, dE\]

Evaluate the integral numerically (e.g., trapezoidal or Simpson’s rule).

Implementation:

Construct the Hamiltonian matrix using a central second-order finite-difference Laplacian with Dirichlet boundary conditions applied only to interior grid points.

Normalize all eigenfunctions and identify left-well states by the sign of their expectation value of \[x\].

Compute the partition function \[Q_r\] from these bound states.

Evaluate the transmission coefficient \[T(E)\] using a stabilized transfer-matrix propagation that rescales matrices to avoid numerical overflow. 

Perform the final integration for \[k(T)\] with the trapezoidal rule to maintain smooth convergence.

*Hints:*

Complex-valued local wavevector:

Use \(k(x) = \sqrt{\frac{2 m_p (E - V(x)+0j)}{\hbar^2}}\), allowing complex values in classically forbidden regions. Do not force \(k\) to be real and do not modify, clamp, regularize, or “fix” \(k\) in any way. 

\[ |k| =  \begin{cases}\Re(k), & \Re(k)>0,\\  |k|, & \text{otherwise}.  \end{cases}\]

Use the interface matrix with the ratio \(r = \frac{k_{j+1}}{k_j }\) (not the Fresnel form \(\frac{k_j - k_{j+1}}{k_j + k_{j+1}}\)).

Perform stabilization by tracking the maximum absolute element (\(m\)) of the transfer matrix.  If \(m\) exceeds \(10^{100}\), divide the entire matrix by \(m\) and multiply the cumulative scale factor (\(S_{\text{total}}\)) by \(m\). If m is below \(10^{-100}\), multiply the matrix by \(m\) and divide the scale factor by \(m\). 

The transmission amplitude must then be computed using the rescaled transfer matrix, \(M\),  by

\[ t = \frac{1}{M_{00} S_{\text{total}}}.\]

Underflow protection: If the computed rate becomes numerically zero, replace it with a small positive value before taking \(\ln(k)\).

**Output requirement**

Report the natural logarithm of the rate constant, \(\ln(k)\) (unitless), evaluated at \(T = 300.000\ \text{K}\).

Round \(\ln(k)\) to three decimal points.

Answer: -521.743

Solution code:
import numpy as np
from scipy.sparse import diags
from scipy.sparse.linalg import eigsh
import math

# -------------------- Exact physical constants (from prompt) --------------------
m_p = 1.6726219e-27            # kg
h = 6.62607015e-34             # J·s
hbar = h / (2.0 * math.pi)
k_B = 1.380649e-23             # J/K
eV_to_J = 1.602176634e-19      # J per eV
T = 300.000
beta = 1.0 / (k_B * T)

# -------------------- Potential parameters (from prompt) --------------------
V0_eV = 0.60                   # eV
a_A = 0.9                      # Å
Delta_eV_per_A = 0.050         # eV/Å

# Convert Delta to SI (J/m) exactly as required
Delta_J_per_m = Delta_eV_per_A * eV_to_J / 1e-10

# -------------------- Grid & domain (prompt) --------------------
N = 2001                       # at least 2001 points
x_A = np.linspace(-3.0, 3.0, N)          # Å
dx_A = x_A[1] - x_A[0]                  # Å
dx = dx_A * 1e-10                        # meters (spacing)
x_m = x_A * 1e-10                        # grid in meters

# Potential function: input x in meters, returns Joules
def V_J_of_x_m(x_m_val):
    # first term is naturally written in Å per prompt; convert x to Å for that term
    x_A_val = x_m_val * 1e10
    term_eV = V0_eV * ((x_A_val**2 - a_A**2)**2) / (a_A**4)   # eV
    term_J = term_eV * eV_to_J                                # J
    linear_J = Delta_J_per_m * x_m_val                        # J (since Delta in J/m and x in m)
    return term_J + linear_J

# Evaluate potential on full grid (J)
V_grid = V_J_of_x_m(x_m)

# -------------------- Saddle (barrier) identification --------------------
# Identify minima then take the maximum between wells (saddle) per prompt
dV = np.diff(V_grid)
extrema_idx = np.where(np.sign(dV[:-1]) != np.sign(dV[1:]))[0] + 1
minima = [i for i in extrema_idx if (0 < i < len(V_grid)-1) and (V_grid[i] < V_grid[i-1]) and (V_grid[i] < V_grid[i+1])]

if len(minima) >= 2:
    mid = len(V_grid)//2
    left_min = max([i for i in minima if i < mid], default=minima[0])
    right_min = min([i for i in minima if i > mid], default=minima[-1])
else:
    mid = len(V_grid)//2
    left_min = np.argmin(V_grid[:mid])
    right_min = np.argmin(V_grid[mid:]) + mid

if left_min < right_min:
    seg = V_grid[left_min:right_min+1]
    saddle_idx = left_min + int(np.argmax(seg))
else:
    seg = V_grid[right_min:left_min+1]
    saddle_idx = right_min + int(np.argmax(seg))

V_saddle = V_grid[saddle_idx]
V_barrier = np.max(V_grid)   # global max (equivalent here)

# -------------------- Build Hamiltonian (2nd-order finite difference, Dirichlet BCs) ----
# interior points exclude endpoints (psi=0 at edges)
N_in = N - 2
kin_coeff = - (hbar**2) / (2.0 * m_p * dx**2)   # note this is negative
main_diag = np.full(N_in, -2.0 * kin_coeff) + V_grid[1:-1]
off_diag = np.full(N_in - 1, kin_coeff)
H = diags([off_diag, main_diag, off_diag], offsets=[-1, 0, 1], format='csr')

# -------------------- Solve eigenproblem (low-lying eigenpairs) --------------------
def compute_eigenpairs_until_barrier(H_sparse, V_saddle_J, x_in_m):
    # Try sparse iterative solver first; if it fails or doesn't reach barrier, fall back to dense
    try:
        k_try = min(80, N_in - 2)
        max_k = min(N_in - 2, 600)
        while True:
            E_vals, E_vecs = eigsh(H_sparse, k=k_try, which='SA', tol=1e-8, maxiter=10000)
            idx = np.argsort(E_vals)
            E_vals = E_vals[idx]
            E_vecs = E_vecs[:, idx]
            # if last found eigenvalue reaches or exceeds barrier, or we asked for many, stop
            if E_vals[-1] >= V_saddle_J - 1e-12 or k_try >= max_k:
                break
            k_try = min(max_k, k_try * 2)
    except Exception:
        # dense fallback
        H_dense = H_sparse.toarray()
        E_vals, E_vecs = np.linalg.eigh(H_dense)

    # Normalize eigenvectors on interior grid: sum |psi|^2 * dx = 1
    dx_local = x_in_m[1] - x_in_m[0]
    norms = np.sqrt(np.sum(np.abs(E_vecs)**2, axis=0) * dx_local)
    E_vecs = E_vecs / norms
    return E_vals, E_vecs

x_in_m = x_m[1:-1]   # interior grid in meters
E_vals, E_vecs = compute_eigenpairs_until_barrier(H, V_saddle, x_in_m)

# -------------------- Identify left-well bound states and compute Q_r --------------------
dx_local = x_in_m[1] - x_in_m[0]
x_expect = np.array([np.sum(np.abs(E_vecs[:, i])**2 * x_in_m) * dx_local for i in range(E_vecs.shape[1])])
bound_idx = [i for i, (E, xexp) in enumerate(zip(E_vals, x_expect)) if (xexp < 0.0) and (E < V_saddle)]
E_bound = E_vals[bound_idx]

if len(E_bound) == 0:
    Q_r = 1.0
else:
    Q_r = np.sum(np.exp(-beta * E_bound))

# -------------------- Stabilized transfer-matrix transmission T(E) --------------------
def compute_T_of_E(E_J, V_array_J, x_m_grid, dx_m):
    # local complex wavevector per prompt
    k_local = np.sqrt(2.0 * m_p * (E_J - V_array_J + 0j)) / hbar

    # initialize M and S_total
    M = np.array([[1+0j, 0+0j], [0+0j, 1+0j]], dtype=complex)
    S_total = 1.0 + 0j

    thresh_high = 1e100
    thresh_low = 1e-100

    # propagate across the grid: apply I_j then P_j at each step
    for j in range(len(x_m_grid) - 1):
        kj = k_local[j]
        kj1 = k_local[j+1]

        # zero-wavevector handling (numerically zero)
        # use a conservative tiny threshold to detect numerical zero
        if (abs(kj) == 0) or (abs(kj1) == 0):
            return 0.0

        # interface matrix with r = k_{j+1} / k_j
        r = kj1 / kj
        I = 0.5 * np.array([[1.0 + r, 1.0 - r],
                            [1.0 - r, 1.0 + r]], dtype=complex)

        # propagation matrix using k_{j+1} and dx (dx in meters)
        P = np.array([[np.exp(1j * kj1 * dx_m), 0.0],
                      [0.0, np.exp(-1j * kj1 * dx_m)]], dtype=complex)

        # apply matrices in required order: M <- M · I_j · P_j
        M = M.dot(I).dot(P)

        # stabilization per prompt:
        maxabs = np.max(np.abs(M))
        if maxabs > thresh_high:
            s = maxabs
            # divide all elements of M by s, and simultaneously multiply S_total by s
            M /= s
            S_total *= s
        elif maxabs < thresh_low and maxabs > 0:
            # multiply all elements of M by the reciprocal of maxabs, and divide S_total by the same value
            reciprocal = 1.0 / maxabs
            M *= reciprocal
            S_total /= reciprocal
        # if maxabs == 0 (exact zero matrix), treat as T=0 (numerical pathology)
        elif maxabs == 0:
            return 0.0

    # transmission amplitude t = 1 / (M00 * S_total)
    M00_scaled = M[0, 0] * S_total
    if (M00_scaled == 0) or (not np.isfinite(M00_scaled)):
        return 0.0
    t_amp = 1.0 / M00_scaled

    # |k| definition exactly as prompt for left and right boundaries
    def absk_rule(kval):
        rval = np.real(kval)
        if rval > 0:
            return rval
        else:
            return abs(kval)

    kL = k_local[0]
    kR = k_local[-1]
    kL_mag = absk_rule(kL)
    kR_mag = absk_rule(kR)

    # if |kL| == 0 set T=0 to avoid division by zero
    if kL_mag == 0:
        return 0.0

    T_E = (abs(t_amp)**2) * (kR_mag / kL_mag)

    # ensure 0 <= T <= 1
    if not np.isfinite(T_E):
        return 0.0
    return float(max(0.0, min(1.0, T_E.real)))

# -------------------- Energy grid (at least 4000 points) and evaluation --------------------
n_E = 4000
E_min = 0.0
E_max = 1.25 * V_barrier    # per prompt: E_max = 1.25 * V_barrier (V_barrier = max V(x))
E_grid = np.linspace(E_min, E_max, n_E)

T_vals = np.empty(n_E, dtype=float)
for i, E in enumerate(E_grid):
    T_vals[i] = compute_T_of_E(E, V_grid, x_m, dx)

# -------------------- Thermal rate constant via flux-over-population --------------------
integrand = T_vals * np.exp(-beta * E_grid)
integral = np.trapezoid(integrand, E_grid)   
k_T = integral / (2.0 * math.pi * hbar * Q_r)

# underflow protection
if not (k_T > 0 and np.isfinite(k_T)):
    k_T = 1e-300

ln_k = math.log(k_T)
print(f"{ln_k:.3f}")