Skip to content

Commit fb69e18

Browse files
committed
Add MSD comparison to the surface diffusion example
1 parent d99bc23 commit fb69e18

File tree

3 files changed

+127
-31
lines changed

3 files changed

+127
-31
lines changed

docs/source/development/changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@ and this project adheres to [Effort-based Versioning](https://jacobtomlinson.dev
1515
## [1.1.0] - 2025-11-10
1616

1717
Improved Lorentz model, with a more robust estimate of the exponential correlation time and its uncertainty.
18+
Improved examples.
1819

1920
### Added
2021

2122
- Cloud cover example
23+
- Comparison between STACIE's diffusion coefficient and a more conventional MSD analysis
24+
in the surface diffusion example.
2225

2326
### Changed
2427

docs/source/examples/surface_diffusion.py

Lines changed: 95 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
plot_extras,
3232
plot_fitted_spectrum,
3333
)
34+
from utils import compute_msds
3435

3536
# %%
3637
mpl.rc_file("matplotlibrc")
@@ -75,6 +76,7 @@
7576
ANGLES = np.arange(3) * ALPHA
7677
EV = sc.value("electron volt") / sc.value("atomic unit of energy")
7778
AMPLITUDE = 0.2 * EV
79+
ANGSTROM = 1e-10 / sc.value("atomic unit of length")
7880

7981

8082
def potential_energy_force(coords: ArrayLike) -> tuple[NDArray, NDArray]:
@@ -133,10 +135,10 @@ def plot_pes():
133135
ys = np.linspace(-20, 20, 201)
134136
coords = np.array(np.meshgrid(xs, ys)).transpose(1, 2, 0)
135137
energies = potential_energy_force(coords)[0]
136-
cf = ax.contourf(xs, ys, energies / EV, levels=20)
138+
cf = ax.contourf(xs / ANGSTROM, ys / ANGSTROM, energies / EV, levels=20)
137139
ax.set_aspect("equal", "box")
138-
ax.set_xlabel("x [a$_0$]")
139-
ax.set_ylabel("y [a$_0$]")
140+
ax.set_xlabel("x [Å]")
141+
ax.set_ylabel("y [Å]")
140142
ax.set_title("Potential Energy Surface")
141143
fig.colorbar(cf, ax=ax, label="Energy [eV]")
142144

@@ -172,10 +174,13 @@ class Trajectory:
172174
"""The spacing between two recorded time steps."""
173175

174176
coords: NDArray = attrs.field()
175-
"""The time-dependent particle positions."""
177+
"""The time-dependent particle positions, with shape `(natom, 2, nstep)`.
178+
179+
The last index is used for time steps, of which only every `block_size` step is recorded.
180+
"""
176181

177182
vels: NDArray = attrs.field()
178-
"""The time-dependent particle velocities.
183+
"""The time-dependent particle velocities, with shape `(natom, 2, nstep)`.
179184
180185
If block_size is larger than 1,
181186
this attribute contains the block-averaged velocity.
@@ -192,16 +197,16 @@ def empty(cls, shape: tuple[int, ...], nstep: int, timestep: float):
192197
"""Construct an empty trajectory object."""
193198
return cls(
194199
timestep,
195-
np.zeros((nstep, *shape, 2)),
196-
np.zeros((nstep, *shape, 2)),
197-
np.zeros((nstep, *shape)),
198-
np.zeros((nstep, *shape)),
200+
np.zeros((*shape, 2, nstep)),
201+
np.zeros((*shape, 2, nstep)),
202+
np.zeros((*shape, nstep)),
203+
np.zeros((*shape, nstep)),
199204
)
200205

201206
@property
202207
def nstep(self) -> int:
203208
"""The number of time steps."""
204-
return self.coords.shape[0]
209+
return self.coords.shape[-1]
205210

206211

207212
def integrate(coords: ArrayLike, vels: ArrayLike, nstep: int, block_size: int = 1):
@@ -239,10 +244,10 @@ def integrate(coords: ArrayLike, vels: ArrayLike, nstep: int, block_size: int =
239244
vels_block += vels
240245
if istep % block_size == block_size - 1:
241246
itraj = istep // block_size
242-
traj.coords[itraj] = coords
243-
traj.vels[itraj] = vels_block / block_size
244-
traj.potential_energies[itraj] = energies
245-
traj.kinetic_energies[itraj] = (0.5 * MASS) * (vels**2).sum(axis=-1)
247+
traj.coords[..., itraj] = coords
248+
traj.vels[..., itraj] = vels_block / block_size
249+
traj.potential_energies[..., itraj] = energies
250+
traj.kinetic_energies[..., itraj] = (0.5 * MASS) * (vels**2).sum(axis=-1)
246251
vels_block = 0
247252
return traj
248253

@@ -266,11 +271,15 @@ def demo_energy_conservation():
266271
plt.close("energy")
267272
_, ax = plt.subplots(num="energy")
268273
times = np.arange(traj.nstep) * traj.timestep
269-
ax.plot(times, traj.potential_energies, label="potential")
270-
ax.plot(times, traj.potential_energies + traj.kinetic_energies, label="total")
274+
ax.plot(times / PICOSECOND, traj.potential_energies / EV, label="potential")
275+
ax.plot(
276+
times / PICOSECOND,
277+
(traj.potential_energies + traj.kinetic_energies) / EV,
278+
label="total",
279+
)
271280
ax.set_title("Energy Conservation Demo")
272-
ax.set_xlabel("Time [a.u. of time]")
273-
ax.set_ylabel(r"Energy [E$_\mathrm{h}$]")
281+
ax.set_xlabel("Time [ps]")
282+
ax.set_ylabel("Energy [eV]")
274283
ax.legend()
275284

276285

@@ -296,26 +305,34 @@ def demo_chaos():
296305
plt.close("chaos")
297306
_, ax = plt.subplots(num="chaos")
298307
ax.plot([0], [0], "o", color="k", label="Initial position")
299-
ax.plot(traj.coords[:, 0, 0], traj.coords[:, 0, 1], color="C1", label="Trajectory 1")
300308
ax.plot(
301-
traj.coords[:, 1, 0],
302-
traj.coords[:, 1, 1],
309+
traj.coords[0, 0] / ANGSTROM,
310+
traj.coords[0, 1] / ANGSTROM,
311+
color="C1",
312+
label="Trajectory 1",
313+
)
314+
ax.plot(
315+
traj.coords[1, 0] / ANGSTROM,
316+
traj.coords[1, 1] / ANGSTROM,
303317
color="C3",
304318
ls=":",
305319
label="Trajectory 2",
306320
)
307321
ax.set_aspect("equal", "box")
308-
ax.set_xlabel("x [a$_0$]")
309-
ax.set_ylabel("y [a$_0$]")
322+
ax.set_xlabel("x [Å]")
323+
ax.set_ylabel("y [Å]")
310324
ax.legend()
311325
ax.set_title("Two Trajectories")
312326

313327
plt.close("chaos_dist")
314328
_, ax = plt.subplots(num="chaos_dist")
315329
times = np.arange(traj.nstep) * traj.timestep
316-
ax.semilogy(times, np.linalg.norm(traj.coords[:, 0] - traj.coords[:, 1], axis=-1))
317-
ax.set_xlabel("Time [a.u. of time]")
318-
ax.set_ylabel("Interparticle distance [a$_0$]")
330+
ax.semilogy(
331+
times / PICOSECOND,
332+
np.linalg.norm(traj.coords[0] - traj.coords[1], axis=0) / ANGSTROM,
333+
)
334+
ax.set_xlabel("Time [ps]")
335+
ax.set_ylabel("Interparticle distance [Å]")
319336
ax.set_title("Slow Separation")
320337

321338

@@ -367,14 +384,14 @@ def demo_stacie(block_size: int = 1):
367384
plt.close(f"trajs_{block_size}")
368385
_, ax = plt.subplots(num=f"trajs_{block_size}", figsize=(6, 6))
369386
for i in range(natom):
370-
ax.plot(traj.coords[:, i, 0], traj.coords[:, i, 1])
387+
ax.plot(traj.coords[i, 0], traj.coords[i, 1])
371388
ax.set_aspect("equal", "box")
372389
ax.set_xlabel("x [a$_0$]")
373390
ax.set_ylabel("y [a$_0$]")
374391
ax.set_title(f"{natom} Newtonian Pseudo-Random Walks")
375392

376393
spectrum = compute_spectrum(
377-
traj.vels.transpose(1, 2, 0).reshape(2 * natom, traj.nstep),
394+
traj.vels.reshape(2 * natom, traj.nstep),
378395
timestep=traj.timestep,
379396
)
380397

@@ -408,11 +425,11 @@ def demo_stacie(block_size: int = 1):
408425
plt.close(f"extras_{block_size}")
409426
_, axs = plt.subplots(2, 2, num=f"extras_{block_size}")
410427
plot_extras(axs, uc, result)
411-
return result
428+
return traj, result
412429

413430

414431
# %%
415-
result_1 = demo_stacie()
432+
traj_1, result_1 = demo_stacie()
416433

417434
# %% [markdown]
418435
# The spectrum has several peaks related to oscillations of the particles
@@ -464,7 +481,7 @@ def demo_stacie(block_size: int = 1):
464481
# Let's use a block size of 60 to stay on the safe side.
465482

466483
# %%
467-
result_60 = demo_stacie(60)
484+
traj_60, result_60 = demo_stacie(60)
468485

469486
# %% [markdown]
470487
# As expected, there are no significant changes in the results.
@@ -482,6 +499,53 @@ def demo_stacie(block_size: int = 1):
482499
# Taking block averages removes the fastest oscillations,
483500
# causing the integrated autocorrelation time to be dominated by slow diffusive motion.
484501

502+
# %% [markdown]
503+
# ## Comparison to mean-squared displacement analysis
504+
#
505+
# This section does not perform a full regression to derive the diffusion coefficient
506+
# from the mean-squared displacement (MSD) data.
507+
# Instead, it simply computes the MSDs from the trajectories,
508+
# and plots them together with the expected slope from STACIE's analysis above,
509+
# to confirm that STACIE's results are consistent with the MSD analysis.
510+
# This avoids the pernicious choices required for a full regression analysis of the MSD data.
511+
512+
513+
# %%
514+
def plot_msd(traj, result, lags, time_step):
515+
# Integer time lags
516+
lag_times = lags * time_step
517+
natom = traj.coords.shape[0]
518+
msds = compute_msds(
519+
traj.coords.reshape(natom * 2, traj.nstep),
520+
lags,
521+
)
522+
plt.close("msd")
523+
_, ax = plt.subplots(num="msd")
524+
ax.plot(
525+
lag_times / PICOSECOND, msds / ANGSTROM**2, "C0o", label="MSD from trajectories"
526+
)
527+
ax.plot(
528+
lag_times / PICOSECOND,
529+
2 * result.acint * lag_times / ANGSTROM**2,
530+
"C1-",
531+
label="Expected slope",
532+
)
533+
ax.set_xlabel("Lag time [ps]")
534+
ax.set_ylabel("Mean-squared displacement [Å$^2$]")
535+
536+
537+
lags = np.unique(np.logspace(1, 3, 50).astype(int))
538+
plot_msd(traj_1, result_1, lags, TIMESTEP)
539+
540+
# %% [markdown]
541+
# As expected, the simple comparison confirms that STACIE's results
542+
# are consistent with the MSD analysis.
543+
# For sufficiently large lag times, the MSDs increase linearly with time,
544+
# with a slope that corresponds to the diffusion coefficient derived with STACIE.
545+
#
546+
# Note that STACIE only estimates the slope of a straight line fitted to the MSD curve.
547+
# It does not provide information on the intercept.
548+
485549
# %% [markdown]
486550
# ## Regression Tests
487551
#

docs/source/examples/utils.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,38 @@
1111
__all__ = (
1212
"plot_instantaneous_percentiles",
1313
"plot_cumulative_temperature_histogram",
14+
"compute_msds",
1415
)
1516

1617

18+
def compute_msds(series: NDArray[float], lags: NDArray[int]) -> NDArray[float]:
19+
"""Compute the mean squared displacements of one or more time series.
20+
21+
Parameters
22+
----------
23+
series
24+
The time series to compute the MSDs for, with shape `(*prefix, nstep)`.
25+
It is assumed that the last index corresponds to time.
26+
lags
27+
The lag times to consider (in number of time steps), with shape `(nlag,)`.
28+
29+
Returns
30+
-------
31+
NDArray[float]
32+
The mean squared displacements for the specified lag times,
33+
with shape `(nlag,)`.
34+
"""
35+
series = np.asarray(series)
36+
lags = np.asarray(lags)
37+
if lags.ndim != 1:
38+
raise ValueError("lags must be a 1D array")
39+
msds = np.zeros(lags.shape)
40+
for i, lag in enumerate(lags):
41+
diffs = np.diff(series[..., ::lag], axis=-1)
42+
msds[i] = np.mean(diffs**2)
43+
return msds
44+
45+
1746
def plot_instantaneous_percentiles(
1847
ax: plt.Axes,
1948
time: NDArray[float],

0 commit comments

Comments
 (0)