Compute¶
A finished simulation is a pile of coordinates. On its own it proves nothing.
The molpy.compute layer is what turns that pile into quantities you can plot,
publish, and compare against experiment: distribution functions, diffusion
coefficients, order parameters, spectra.
These pages are written to be read, not just searched. Each one starts from the physical question, builds the quantity from something you could in principle measure by hand, shows it on real data, and then gives the code. Full signatures live in the API reference.
If you are new here, read NeighborList and RDF first, in that order. Most of the structural analyses are variations on those two, and the conventions introduced there apply everywhere.
How a compute is used¶
Every analysis follows the same three beats: configure an object, call it on data, read the result.
import numpy as np
import molpy as mp
from molpy.compute import NeighborList, RDF
rng = np.random.default_rng(0)
xyz = rng.uniform(0.0, 20.0, size=(200, 3))
frame = mp.Frame()
frame["atoms"] = {"x": xyz[:, 0], "y": xyz[:, 1], "z": xyz[:, 2]}
frame.box = mp.Box.cubic(20.0)
nlist = NeighborList(cutoff=10.0)(frame) # configure, then call
result = RDF(n_bins=100, r_max=10.0)([frame], [nlist])
print(result.rdf.shape, result.bin_centers.shape) # -> (100,) (100,)
The parameters that define the measurement (cutoff, n_bins, r_max) go into
the constructor; the data goes into the call. That separation is deliberate — it
means one configured analyzer can be applied to many trajectories and the
measurement stays identical.
Two things to know about what comes back¶
Results are not uniform across the layer, and expecting the wrong one is the most common early mistake.
Some computes return a result object with named fields, like RDF above
(.rdf, .bin_centers, .n_frames). Others return a plain Python list with
one entry per frame, where each entry is a tuple of arrays:
from molpy.compute import LocalDensity
per_frame = LocalDensity(r_max=10.0)([frame], [nlist])
print(len(per_frame), len(per_frame[0])) # -> 1 2
One frame in, one entry out, and that entry is a 2-tuple of
(num_neighbors, density). It is an ordinary list, so you can index it, iterate
it more than once, and pass it around. When you have exactly one frame it reads
better to unpack immediately:
That trailing comma is doing real work. (a, b), = xs means "xs has exactly
one element, and that element is a pair; name its parts a and b". Drop the
comma and you get a ValueError about unpacking. If it looks too clever, the
long form counts, density = per_frame[0] is identical.
The per-page "Computing it" section always states the exact shape returned, and the API reference lists them all.
Frame-oriented and array-oriented analyses¶
Structural analyses take frames, because "where are the atoms" is the question. Transport and dielectric analyses instead take pre-assembled time series — the collective dipole \(\mathbf{M}(t)\), the current \(\mathbf{J}(t)\), per-species centre-of-mass sums \(\mathbf{P}_\alpha(t)\) — because only you know which molecules and which charges belong in the sum.
Those follow an explicit compose pattern: a raw correlation function comes out of the compute, and turning it into a transport coefficient is a separate, visible step.
from molpy.compute import EinsteinConductivity, LinearFit
rng = np.random.default_rng(0)
M = np.ascontiguousarray(np.cumsum(rng.normal(0, 0.01, size=(80, 3)), axis=0))
raw = EinsteinConductivity().compute(M, dt=10.0, max_correlation_time=20)
print(sorted(raw)) # -> ['lag_times', 'msd']
Three details in those two lines regularly trip people up.
They are called with .compute(...), not (...). Frame-oriented analyses
are callable objects; the array-oriented ones expose a named compute method.
The per-page examples show which is which.
A conductivity returns something called msd. That is not a mistake. The
Einstein route to conductivity is the mean squared displacement of the
collective charge dipole \(\mathbf{M}(t) = \sum_a q_a \mathbf{r}_a\) — the same
mathematics as a particle MSD, applied to a different vector. See
PMSD.
The fit window is a fraction, not a time. LinearFit(0.1, 0.5) fits the
straight line over the portion of the curve from 10 % to 50 % of its length,
which is how you exclude the short-time transient and the noisy tail:
fit = LinearFit(0.1, 0.5).fit(raw["lag_times"], raw["msd"])
print(sorted(fit)) # -> ['fit_end', 'fit_start', 'intercept', 'r2', 'slope']
r2 is there so you can check the window really was linear before quoting the
slope.
Nothing hides that window or the unit conversion behind a convenience wrapper. If a published number depends on where you chose to fit, that choice belongs in your script where a reviewer can see it — not buried in a library default.
Conventions that apply to every page¶
Units and coordinates
- Time is femtoseconds (LAMMPS real units). Vibrational spectra take
dt_fsand report cm⁻¹. - Length is Å, charge is \(e\), volume ų, temperature K. GROMACS is nm-native — multiply lengths by 10 on the way in.
- Displacement kernels need unwrapped coordinates. Dynamics dumps are
normally written that way already (
xu yu zu, no-jump, …). Pass those frames straight in. Wrappedx y zwithout images turns a diffusion curve into box-sized jumps. See MSD. - Structural kernels need a periodic box.
frame.boxmust be set. A free box — one with no periodicity, the state of aFrameyou built without settingbox— makes these raise rather than silently guess. - Everything is an ensemble average. Statistical mechanics predicts averages over all the microstates a system visits, not the value in any one of them. In practice you approximate that by averaging over frames of a trajectory (and, for most of these quantities, over every atom and every time origin too). A single frame is one sample, not a measurement.
Starting from your own trajectory¶
Structural demos on these pages often build a single Frame with NumPy so the
snippet is self-contained. Trajectory / dynamics work starts from a file.
For a LAMMPS dump of unwrapped coordinates:
# docs: skip — needs a trajectory file of your own
from molpy.io import read_lammps_trajectory
from molpy.compute import NeighborList, RDF
reader = read_lammps_trajectory("run.lammpstrj")
frames = reader.read_all() # list[Frame]; frame.box from BOX BOUNDS
nlists = [NeighborList(cutoff=8.0)(f) for f in frames]
gr = RDF(n_bins=160, r_max=8.0)(frames, nlists)
Prefer dumps that already store continuous paths (xu yu zu, or x y z plus
ix iy iz unwrapped on read). There is no molpy helper that “turns a NumPy
array into a trajectory” for you — MSD, VanHove, and friends take a sequence
of Frames.
Use reader.read_frame(i), read_range, or read_frames instead of
read_all() when the trajectory does not fit in memory. Other formats live in
molpy.io (read_gro, read_dcd_trajectory, …).
Partial (species-resolved) distributions¶
There is no species argument on RDF or the other structural computes. To get
an O–O rather than an all-pairs \(g(r)\), build a frame containing only the atoms
you want and analyse that:
rng = np.random.default_rng(0)
xyz = rng.uniform(0.0, 20.0, size=(300, 3))
kinds = rng.integers(1, 3, 300)
mixture = mp.Frame()
mixture["atoms"] = {
"x": xyz[:, 0], "y": xyz[:, 1], "z": xyz[:, 2], "type_id": kinds
}
mixture.box = mp.Box.cubic(20.0)
selected = mixture["atoms"]["type_id"] == 1
subset = mp.Frame()
subset["atoms"] = {k: mixture["atoms"][k][selected] for k in ("x", "y", "z")}
subset.box = mixture.box
print(subset["atoms"].nrows) # -> 143
The density used for normalization then comes from the subset, which is what you want for a like–like partial. A cross-species \(g_{AB}(r)\) needs a neighbour query between two different point sets and is not covered by the self-query wrapper here.
Which analysis answers which question¶
The pages are grouped the way the questions group, not the way the classes do.
Structure — where the atoms are¶
| Question | Page | Entry points |
|---|---|---|
| Which atoms are near which? | NeighborList | NeighborList |
| How are neighbours spaced? | RDF | RDF |
| Where is the matter, in space? | Density | LocalDensity, GaussianDensity |
| What would a diffraction experiment see? | Diffraction | StaticStructureFactorDebye |
| Where do neighbours sit around an anisotropic particle? | PMFT | PMFTXY |
| How are bond lengths, angles, torsions distributed? | Distribution | DistanceDistribution, AngleDistribution, DihedralDistribution |
| Where do neighbours sit in a molecule's own frame? | Spatial | SpatialDistribution |
Order, shape, and aggregates — what the structure adds up to¶
| Question | Page | Entry points |
|---|---|---|
| Crystalline, hexatic, or nematic order? | Order | Steinhardt, Hexatic, Nematic, SolidLiquid |
| What does the local environment look like? | Environment | BondOrder |
| How big and how anisotropic is a molecule? | Shape | RadiusOfGyration, GyrationTensor, InertiaTensor |
| What is connected to what? | Cluster | Cluster, ClusterProperties |
| What are the dominant collective coordinates? | Decomposition | Pca, KMeans |
| Which atoms are hydrogen-bonded? | HBond | HBonds, HBondCriterion |
| How much space does each atom own? | Voronoi | RadicalVoronoi, VoronoiIntegration |
Motion — how things move and how fast¶
| Question | Page | Entry points |
|---|---|---|
| How far do atoms wander? (self-diffusion) | MSD | MSD |
| What does the velocity remember? | VACF | Acf, signal.acf_fft |
| Ionic conductivity, from displacements? | PMSD | EinsteinConductivity → LinearFit |
| …and from currents? | JACF | GreenKuboConductivity → CumulativeTrapezoid |
| Do ions move together or independently? | Onsager | Onsager.correlation |
| How long does a contact survive? | Persist | Persist.pair_survival_tcf |
| How does a density fluctuation decay? | Van Hove | VanHove |
| How fast do molecules tumble? | Reorientation | LegendreReorientation |
Spectra and composition¶
| Question | Page | Entry points |
|---|---|---|
| What is \(\varepsilon^*(\omega)\)? | Dielectric | Dielectric, EH/GK spectra, fits |
| Vibrational, IR, or Raman spectrum? | Spectra | PowerSpectrum, IRSpectrum, RamanSpectrum, … |
| How do I window, transform, and correlate a signal? | Signal | signal.acf_fft, apply_window |
| How do I chain several analyses together? | Workflow | Workflow |
Where the figures come from¶
Every curve on these pages is computed, not drawn. The reference system is 500
argon atoms at 85 K and 1.374 g cm⁻³ — the Rahman state point — integrated for
30 ps at constant energy, conserving total energy to a relative drift of
\(1.4\times10^{-5}\) (dimensionless, \(|E(t)-E(0)|/|E(0)|\)). The
generator lives in scripts/docs_data/ and writes to docs/data/, so any
figure can be reproduced or challenged:
# docs: skip — runs a 30 ps MD trajectory (minutes, not seconds)
from docs_data.run import argon_trajectory
from docs_data.structure import radial_distribution
radial_distribution(argon_trajectory())
Where no honest dataset exists yet, the page says so and carries a TODO
instead of a decorative sketch.