This chapter explains what every line of surfacehop_jax.dynamics.step
actually does. If you just want to run a calculation, skip to
Quickstart and come back later; nothing in the
package’s public API requires reading this chapter. But if a number
looks wrong, or you want to extend the propagator, this is where to
start.
For a system with $n_\mathrm{el}$ electronic states and a nuclear coordinate vector $\mathbf{Q}$ of dimension $n_\mathrm{dim}$, the electronic Hamiltonian at a fixed nuclear geometry is an $n_\mathrm{el} \times n_\mathrm{el}$ matrix $\mathbf{H}(\mathbf{Q})$. You can pick the basis you write it in:
surfacehop_jax takes the diabatic Hamiltonian as user input (it’s the
natural form for an LVC or for an ML-fit potential) and computes
adiabatic energies, gradients, and NACs internally via autodiff. The
trajectory itself lives on adiabatic surfaces — this is what “surface”
in “surface hopping” refers to.
adiabatic_quantities worksGiven any callable H(x) -> (nel, nel) Hermitian matrix,
pes.adiabatic_quantities returns the
energies, gradients, NACs, and eigenvectors at a single point x:
H = diab_h_fn(x) # (nel, nel)
energies, eigvecs = jnp.linalg.eigh(H) # (nel,), (nel, nel)
dH = jax.jacrev(diab_h_fn)(x) # (nel, nel, ndim)
T = jnp.einsum("ai,abk,bj->ijk",
eigvecs, dH, eigvecs) # (nel, nel, ndim)
T[i, j, k] is $\langle \psi_i | \partial H / \partial x_k | \psi_j\rangle$,
the matrix element of the nuclear gradient of the (diabatic) Hamiltonian
sandwiched between adiabatic eigenstates. By Hellmann–Feynman:
That’s it — one eigh, one jacrev, and one einsum, and we have
everything the FSSH propagator needs. The 1/(E_j - E_i) denominator
is handled with the “double-where” pattern to avoid NaN gradients on
the diagonal.
A single FSSH trajectory carries two pieces of state besides position and velocity:
s ∈ {0, 1, ..., nel-1}: the adiabatic surface the
nuclei are currently moving on. Integer.Newton’s equations on the active surface:
\[m_k \ddot{Q}_k = -\frac{\partial E_s}{\partial Q_k}.\]We integrate this with velocity Verlet, which is symplectic and so conserves energy on average between hops (drift is purely from the discrete hops themselves; see momentum rescaling below).
The amplitudes evolve under the time-dependent Schrödinger equation in the adiabatic basis, with the nuclear velocity coupling adiabatic states through their NACs:
\[i\hbar \, \dot{c}_i = E_i \, c_i - i\hbar \sum_j (\mathbf{v} \cdot \mathbf{d}_{ij}) \, c_j.\]Define the generator
\[G_{ij} = -\frac{i E_i}{\hbar} \delta_{ij} - \mathbf{v} \cdot \mathbf{d}_{ij}.\]Then $\dot{\mathbf{c}} = \mathbf{G} \mathbf{c}$, and we propagate over a nuclear step $\Delta t$ by exact matrix exponential:
\[\mathbf{c}(t + \Delta t) = e^{\mathbf{G}\,\Delta t} \, \mathbf{c}(t).\]This is jax.scipy.linalg.expm(G * dt) @ coeffs in the code. It is
exact for constant $\mathbf{G}$ over the step (we hold the NACs and
velocity fixed across $[t, t+\Delta t]$). Higher-order integrators that
interpolate $\mathbf{G}(t)$ across the step would be a straightforward
extension; the matrix-exponential single-step scheme is robust and
sufficient for time steps $\Delta t \lesssim 1$ a.u. ≈ 0.024 fs in
typical photochemistry problems.
Why matrix exponential instead of
solve_ivp? The naive choice would be SciPy’ssolve_ivpwith RK45 or LSODA. Both work, but complex-valued ODE support in SciPy is poor (you have to split into real/imaginary parts), and neither isjit-able orvmap-able. Matrix exponential is exact for constant generator, JAX-native, and the entire electronic step compiles into XLA.
Tully’s central result: if you ask “what is the smallest rate of $i \to j$ hops in the ensemble that keeps the fraction of trajectories on state $j$ equal to $\langle |c_j|^2 \rangle$?”, you get
\[P_{i \to j}(t \to t + \Delta t) = \max\!\left\{0,\; \frac{2\,\Delta t \, \mathrm{Re}\!\left[\rho_{ij}^{*}\,(\mathbf{v} \cdot \mathbf{d}_{ij})\right]}{\rho_{ii}}\right\},\]where $\rho_{ij} = c_i c_j^{}$ is the (one-trajectory) density matrix. The numerator is the flux of population *out of state $i$ along the $i$–$j$ coupling channel; dividing by $\rho_{ii}$ converts it to a conditional transition probability given we’re currently in $i$.
In step() this reads:
b = 2.0 * jnp.real(jnp.conj(rho[state.state, :]) * vdotd)
g = jnp.maximum(0.0, b * dt / (rho_ii + 1e-30))
g = g.at[state.state].set(0.0) # never "hop to self"
A subtle sign. The derivation in Tully 1990 has terms with both $\mathbf{d}{ij}$ and $\mathbf{d}{ji} = -\mathbf{d}_{ij}$ (antisymmetry of the NAC tensor). Depending on which index you “factor out” of the $\mathrm{Re}[\cdots]$ trace, you get either a plus or a minus sign in front. The correct convention, verified by reproducing Tully Model 1 transmission within statistical noise, is
b = +2 Re(rho[i,j]^* v·d[i,j]). An early bug in this package had the wrong sign; trajectories ran but the transmission curve was wrong. The Tully-1 benchmark test (tests/test_tully1_benchmark.py) guards against this.
For $n_\mathrm{el} = 2$ the hop decision is just one comparison. For $n_\mathrm{el} \geq 3$ Tully’s original algorithm “draw a uniform $u$, hop to state $j$ if $u < g_{i \to j}$” is biased: with three states and equal probabilities to states 1 and 2, comparing the same $u$ to each one in sequence is not the same as drawing one outcome from a categorical distribution. The unbiased version is the cumulative-probabilities scheme:
cum = jnp.cumsum(g)
u = jax.random.uniform(key, ())
target = jnp.argmax(cum > u) # first bin where cumulative > u
This is what surfacehop_jax uses. It’s correct for any number of
states and reduces to the single-u-comparison for $n_\mathrm{el} = 2$.
When the trajectory hops from state $i$ to state $j$, the total energy must be conserved. The classical kinetic energy must absorb the change in potential energy $\Delta E = E_i - E_j$ (positive when going down, so kinetic energy increases). Tully prescribes that the rescaling is applied along the NAC direction:
\[\mathbf{v}_\mathrm{new} = \mathbf{v} - \gamma\,\frac{\mathbf{d}_{ij}}{\mathbf{m}},\]with $\gamma$ found by solving the quadratic from energy conservation:
\[\tfrac{1}{2}\sum_k \frac{d_{ij,k}^{2}}{m_k}\,\gamma^{2} \;-\;(\mathbf{v}\cdot\mathbf{d}_{ij})\,\gamma \;-\;\Delta E = 0.\]This has two real roots when the discriminant is non-negative; Tully’s prescription (refined by Hammes-Schiffer & Tully 1994) is to take the root closer to zero (minimal velocity perturbation).
If the discriminant is negative, there is no real $\gamma$ that conserves energy: the trajectory is trying to hop up into a state it doesn’t have enough kinetic energy along the NAC to reach. This is a frustrated hop. The trajectory stays on its original state, but Truhlar’s 2002 prescription is to reverse the velocity component along the NAC:
\[\mathbf{v}_\mathrm{new} = \mathbf{v} - \frac{2(\mathbf{v}\cdot\mathbf{d}_{ij})}{\sum_k d_{ij,k}^{2}/m_k}\,\frac{\mathbf{d}_{ij}}{\mathbf{m}}.\]This reflects the trajectory back toward the coupling region for
another chance at a successful hop on a later step, and gives better
detailed balance than simply “do nothing”. It is what surfacehop_jax
implements.
Implementation detail. The momentum-rescaling routine is invoked unconditionally on every step (with a placeholder NAC vector of zero when no hop is attempted), then the caller selects the pre-hop velocity if no hop occurred. This avoids a
lax.condthat XLA can’t fully optimise. The “double-where” pattern around the discriminant prevents0/0from poisoning the autodiff backward pass.
jnp.linalg.eigh is an excellent eigensolver, but it returns
eigenvectors with arbitrary signs: nothing in the eigen problem
distinguishes $\boldsymbol{\psi}$ from $-\boldsymbol{\psi}$. As the
trajectory walks across the PES, the sign returned at step $t+\Delta t$
may flip independently for each eigenvector. The NAC vectors
$\mathbf{d}_{ij}$ depend on the relative phases of $\psi_i$ and
$\psi_j$, so an unflagged sign flip would invert the sign of the NAC
between steps and turn the trajectory into nonsense.
pes.fix_eigenvector_phase(new, old) returns a length-nel vector of
$\pm 1$ signs such that each new eigenvector best overlaps with the
previous one. The next step’s eigenvectors are multiplied by these
signs before the NACs are computed. This makes the NAC tensor
continuous along the trajectory.
Two failure modes to keep in mind:
At an exact conical intersection, the eigenvalues are degenerate
and eigh returns an arbitrary basis. Phase tracking based on
single-step overlap fails. In practice for finite-dimensional model
Hamiltonians one never lands on the CoIn exactly; if you find
trajectories blowing up on a specific step, the time step is
probably too large, or you are very near (but not on) a CoIn and
should reduce $\Delta t$ near coupling regions.
For trajectories that explore the coordinate widely, the single-step phase-tracking heuristic can drift over very many steps. The propagator is internally consistent — phase is corrected step-to-step — but if you compare absolute eigenvector orientations across thousand-step trajectories, expect arbitrary sign flips. Observables are sign-invariant.
A correctly implemented FSSH satisfies the internal-consistency property: the fraction of trajectories on adiabatic state $j$ at time $t$ should equal the ensemble-averaged $|c_j(t)|^2$:
\[\frac{1}{N_\mathrm{traj}} \sum_{\alpha=1}^{N_\mathrm{traj}} \mathbf{1}[s^{(\alpha)}(t) = j] \;\overset{?}{=}\; \frac{1}{N_\mathrm{traj}} \sum_\alpha |c_j^{(\alpha)}(t)|^{2}.\]This is the whole point of the fewest-switches construction. If your trajectory fraction is systematically far from the average $|c|^2$, something is wrong:
b, missing/wrong frustrated-hop handling, wrong NAC
computation, eigenvector phase not tracked.The Tully-1 benchmark (tests/test_tully1_benchmark.py) checks both
internal consistency and the transmission curve against Tully’s 1990
published values; it would catch the major implementation bugs in this
category.
The known shortcomings of FSSH, in rough order of severity:
For a survey of FSSH variants and improvements, see Subotnik et al., Annu. Rev. Phys. Chem. 67, 387 (2016).
Now that the theory is laid out, see how it translates to working code in 04 — Quickstart.