Reorientation¶
A molecule in a liquid does two things: it moves, and it turns. MSD measures the moving. This page measures the turning.
Rotational relaxation is what NMR spin-lattice relaxation, dielectric spectroscopy, and fluorescence anisotropy all probe, so it is one of the few dynamical quantities you can compare against experiment fairly directly — but only if you use the same \(\ell\) the experiment does.
Two correlation functions, not one¶
Track a unit vector \(\mathbf{u}(t)\) fixed in the molecule — an O–H bond, a dipole, the long axis of a rod. Its reorientation is measured by the Legendre correlation functions
with \(P_1(x) = x\) and \(P_2(x) = \tfrac12(3x^2-1)\). Both start at 1 and decay to 0 as the molecule forgets its initial orientation.
Why two? Because different experiments couple to different ranks. \(C_1\) is what dielectric relaxation and infrared absorption see, since those depend on the dipole direction. \(C_2\) is what NMR and depolarized light scattering see, because those depend on the axis rather than its sign — \(P_2\) is unchanged by \(\mathbf{u} \to -\mathbf{u}\). Quoting "the rotational correlation time" without saying which \(\ell\) is a common and avoidable ambiguity.
For simple rotational diffusion with rotational diffusion constant \(D_r\) there is an exact answer:
so
independently of \(D_r\), of temperature, and of the molecule. That ratio is the most useful diagnostic on this page: it holds whenever reorientation proceeds by many small random steps, and it breaks when it does not — jump reorientation, strong hydrogen-bond networks, and glassy systems all push it away from 3.
Testing it where the answer is known¶
Here are 300 rods undergoing pure rotational diffusion — small random rotations, nothing else — with \(C_1\) and \(C_2\) computed from the resulting trajectory.
Figure 1. \(C_1\) and \(C_2\) for rods undergoing rotational diffusion. Both decay exponentially; the fitted relaxation times are 276 and 91 frames, a ratio of 3.04 against the exact 3.
That agreement is the validation — of the compute, of the estimator, and of the sampling. On a real molecular liquid the same measurement becomes physics: a ratio near 3 says diffusive reorientation, and a ratio far from it says the molecule is turning by some other mechanism.
The correlation is measured on unit vectors you choose¶
ReorientationAcf — the orientational autocorrelation function, or OACF —
correlates an (n_frames, n_vectors, 3) array of vectors.
The vector that matters is rarely given to you by a file: it is the O–H
direction of every water molecule under the minimum image, a molecular dipole,
the normal of an aromatic ring, the long axis of a chain. Building it is a few
lines of NumPy you want to see; the correlation is the part you do not want to
write twice. Each vector is normalised inside the kernel, so only its direction
counts, and a zero vector — almost always the wrong pair of atoms — is refused
with a ValueError.
The rods below are stored the way a simulation would store them: two atoms per
rod in a frame, with a bonds block naming the pair.
import numpy as np
import molpy as mp
from molpy.compute import ReorientationAcf
rng = np.random.default_rng(0)
n_rods, n_frames = 300, 400
axis = rng.normal(size=(n_rods, 3))
axis /= np.linalg.norm(axis, axis=1, keepdims=True)
centres = rng.uniform(0.0, 100.0, size=(n_rods, 3))
bonds = {
"atomi": np.arange(n_rods, dtype=np.uint32),
"atomj": np.arange(n_rods, 2 * n_rods, dtype=np.uint32),
}
frames = []
for _ in range(n_frames):
kick = rng.normal(0.0, 0.06, size=(n_rods, 3))
kick -= (kick * axis).sum(axis=1, keepdims=True) * axis # rotation only
axis = axis + kick
axis /= np.linalg.norm(axis, axis=1, keepdims=True)
pos = np.concatenate([centres - 0.5 * axis, centres + 0.5 * axis])
frame = mp.Frame()
frame["atoms"] = {"x": pos[:, 0], "y": pos[:, 1], "z": pos[:, 2]}
frame.box = mp.Box(mp.core.Cell.lengths([200.0] * 3))
frame["bonds"] = bonds
frames.append(frame)
Subtracting the component along axis before adding the kick is what keeps the
motion a rotation: a displacement along the axis would change the rod's length
instead of its direction.
Reading the tracked vectors is your step, through the frame's own blocks: the bond endpoints index the coordinates, and the box gives the minimum-image displacement from atom i to atom j.
def bond_vectors(frame):
xyz = frame.coords
bonds = frame["bonds"]
return frame.box.delta(
xyz[bonds["atomi"]], xyz[bonds["atomj"]], minimum_image=True
)
u = np.stack([bond_vectors(frame) for frame in frames]) # (n_frames, n_rods, 3)
One instance measures one order, so \(C_1\) and \(C_2\) are two configured
correlators applied to the same vectors. max_lag is in frames; dt is the
time between frames, and sets the unit of lag_times:
c1 = ReorientationAcf(1, 200).compute(vectors=u, dt=1.0)
c2 = ReorientationAcf(2, 200).compute(vectors=u, dt=1.0)
print(round(float(c1.values[0, 0]), 3), round(float(c2.values[0, 0]), 3)) # -> 1.0 1.0
print(c1.values.shape) # -> (201, 1)
Both start at exactly 1, as any normalised correlation function must — the
first thing to check. The result is a TimeCorrelation: values[lag, channel]
at lag_times[lag], here a single channel because the curve is averaged over
every rod and every time origin.
The relaxation time is a fit, or an integral¶
For a single exponential the relaxation time is the decay constant of the
curve. DebyeFit fits \(C(t) = A\,e^{-t/\tau}\) to one channel of a correlation:
from molpy.compute import DebyeFit
tau1 = DebyeFit().compute(correlation=c1, channel=0).tau
tau2 = DebyeFit().compute(correlation=c2, channel=0).tau
print(round(tau1 / tau2, 2)) # -> 3.02
The fit assumes the decay is a single exponential. The integral
\(\tau_\ell = \int_0^\infty C_\ell(t)\,\mathrm{d}t\) does not, and it is the number
to quote when the decay is stretched, as it is in water. The running integral
of a correlation is CumulativeTrapezoid, itself a TimeCorrelation; its last
value is the integral over the computed lags, meaningful only once the curve has
reached zero inside them:
from molpy.compute import CumulativeTrapezoid
running = CumulativeTrapezoid().compute(correlation=c2, channel=0)
print(running.n_lags == c2.n_lags) # -> True
How it is computed¶
\(C_1\) is the vector autocorrelation itself, one FFT per vector and component.
\(C_2\) looks like it should not go through an FFT, because the square sits inside
the average — but \((\hat{\mathbf{u}}\cdot\hat{\mathbf{v}})^2\) is the dot
product of the six independent components of
\(\hat{\mathbf{u}}\otimes\hat{\mathbf{u}}\) (the three squares once, the three cross
terms twice), so
\(C_2 = \tfrac32\langle Q(\tau)\cdot Q(\tau+t)\rangle - \tfrac12\) is six FFTs per
vector. Both orders are exact, \(\mathcal{O}(T\log T)\), and run on the same
estimator as every other correlation in molpy, Correlator with the
legendre kernel; orders above 2 fall back to the direct sum over origins.
Per-vector curves and fewer origins¶
reduction="per_item" keeps one curve per vector instead of the mean — one
channel per rod — and stride takes every s-th frame as a time origin: four
times fewer origins, four times faster, and the same curve to within noise. It
is the way to make a long trajectory affordable.
per_rod = ReorientationAcf(2, 50, reduction="per_item", stride=4).compute(
vectors=u, dt=1.0
)
print(per_rod.values.shape) # -> (51, 300)
O–H vectors in water are built the same way¶
For water the vector is the H → O direction, one per hydrogen, under the minimum image. With your own trajectory loaded as frames, the vectors and the correlation are:
# docs: skip — needs your own trajectory
def oh_vectors(frame, h_idx, o_of_h):
"""H -> bonded O vectors for one frame, minimum image, shape (n_H, 3)."""
xyz = frame.coords
return frame.box.delta(xyz[h_idx], xyz[o_of_h], minimum_image=True)
u = np.stack([oh_vectors(f, h_idx, o_of_h) for f in frames])
c2 = ReorientationAcf(2, 2000).compute(vectors=u, dt=0.05) # dt in ps
tau2 = CumulativeTrapezoid().compute(correlation=c2, channel=0).values[-1, 0]
Rigid-body orientations correlate through Wigner functions¶
When the simulation stores each molecule's orientation as a unit quaternion —
rigid bodies, coarse-grained ellipsoids — the vector is implicit, and the
rank-\(\ell\) correlation is the average of the Wigner \(D\)-matrix of the relative
rotation between \(t_0\) and \(t_0 + t\). RotationalAutocorrelation takes the
(n_frames, n_bodies, 4) quaternions, and follows freud's convention of a
single time origin, the first frame. Rotational diffusion gives the same
\(\ell(\ell+1)\) ratio:
from molpy.compute import RotationalAutocorrelation
def rotate(q, w):
"""Compose each quaternion q with the small rotation of angle vector w."""
dq = np.concatenate([np.ones((len(w), 1)), 0.5 * w], axis=1)
dq /= np.linalg.norm(dq, axis=1, keepdims=True)
a0, a, b0, b = dq[:, :1], dq[:, 1:], q[:, :1], q[:, 1:]
out = np.concatenate(
[a0 * b0 - (a * b).sum(1, keepdims=True), a0 * b + b0 * a + np.cross(a, b)],
axis=1,
)
return out / np.linalg.norm(out, axis=1, keepdims=True)
rng = np.random.default_rng(1)
q = np.tile([1.0, 0.0, 0.0, 0.0], (100, 1))
quaternions = np.empty((300, 100, 4))
for t in range(300):
q = rotate(q, rng.normal(0.0, 0.03, size=(100, 3)))
quaternions[t] = q
r1 = RotationalAutocorrelation(1, 100).compute(orientations=quaternions, dt=1.0)
r2 = RotationalAutocorrelation(2, 100).compute(orientations=quaternions, dt=1.0)
ratio = (
DebyeFit().compute(correlation=r1, channel=0).tau
/ DebyeFit().compute(correlation=r2, channel=0).tau
)
print(round(ratio, 1)) # -> 3.0
When it goes wrong¶
ValueError about a zero vector.
Two atoms of a pair coincide — almost always the wrong pairing. Check the
indices that built the vectors.
\(C_\ell(0) \ne 1\). The vectors are not the ones you meant — a wrong column, or vectors mixed between frames. Stop here.
\(C_2\) decays more slowly than \(C_1\). Impossible for real reorientation — \(\tau_1 > \tau_2\) always. Suspect a swapped assignment.
\(\tau_1/\tau_2\) is far from 3. Sometimes physics, sometimes not. Genuine causes: jump reorientation, anisotropic rotation, hydrogen-bond-mediated turning. Artefacts: too short a trajectory, or fitting a stretched exponential with a single time. Check the fit residuals before claiming the physics.
The curves are noisy at long lag. Same time-origin problem as MSD: few independent origins remain out there. Fit the early, well-sampled decay.
The correlation does not reach zero. Something is restricting rotation — a frozen system, a rod stuck in a crystalline environment, or a constraint you forgot. In a liquid it must decay.
Check yourself¶
- Confirm both curves start at 1.00.
- Fit \(\tau_1\) and \(\tau_2\) and check the ratio. On a diffusively reorienting system you should get 3 to within a few percent, as above.
- Halve the angular step in the example. Both times should quadruple (the rotational diffusion constant scales with the step squared) and the ratio should not move.
- Compare the
DebyeFittime with the integral. If they disagree, the decay is not a single exponential and the fitted \(\tau\) is not the number to quote.
References¶
- P. Debye, Polar Molecules, Chemical Catalog Company (1929) — rotational diffusion and \(C_1\).
- D. A. McQuarrie, Statistical Mechanics, Harper & Row (1976), ch. 21 — Legendre reorientational correlation functions.
- A. Luzar, D. Chandler, J. Chem. Phys. 98, 8160 (1993) — reorientation in hydrogen-bonded liquids, where the ratio departs from 3.
- B. J. Berne, R. Pecora, Dynamic Light Scattering, Wiley (1976), ch. 7 — which spectroscopy sees which \(\ell\).
- G. Lipari, A. Szabo, J. Am. Chem. Soc. 104, 4546 (1982) — \(C_2\) as the NMR relaxation correlation function.
- D. Laage, J. T. Hynes, Science 311, 832 (2006) — O–H reorientation in water by large-angle jumps.
See also¶
- VACF — the
TimeCorrelationa correlation returns, and its fits - Van Hove — the translational partner of this measurement
- Dielectric — where \(C_1\) becomes a measurable spectrum
- Persist — how long the neighbours it turns among survive
- API reference