Density¶
\(g(r)\) answers "how far apart are atoms, on average" — it averages over every direction and every atom. Sometimes that is exactly the wrong question. If your box contains a droplet, a slab, a pore, or an interface, you want to know where the matter is, not just how it is spaced.
Two computes answer that, and they answer different questions.
Two ways to ask "how dense is it here"¶
LocalDensity gives every atom a number. Put a sphere of radius r_max
around atom \(i\), count the atoms inside, divide by the sphere's volume:
You get one density per atom, so you can colour a rendering by it, histogram it, or select the atoms in the dense phase.
GaussianDensity gives space a number. Forget which atom is which; smear
each one into a Gaussian of width \(\sigma\) and add them all onto a fixed grid:
You get a continuous field on a grid, which is what you want for volume rendering, for slicing along an interface normal, or for feeding an adsorption map.
The first is per-particle and needs a neighbor list. The second is per-voxel and does not.
The probe radius is the whole story¶
Neither number means anything until you say over what length scale it was measured. This is the part students trip on, so it is worth seeing directly.
Below is the distribution of per-atom local density in homogeneous liquid argon — a system with no interfaces, no droplets, nothing to find — measured with two probe radii.
Figure 1. Distribution of local densities in bulk liquid argon at 85 K, for probe radii of 4 Å and 8 Å. Both are centred on the same physical density; the narrow one is not "better data", it is more smoothing.
Both distributions sit on the bulk density, 0.0207 Å⁻³. What differs is the width:
Probe r_max |
⟨N⟩ in the sphere | mean \(\rho\) (Å⁻³) | spread | Poisson would give |
|---|---|---|---|---|
| 4 Å | 5.5 | 0.0206 | 27 % | 43 % |
| 8 Å | 45.1 | 0.0210 | 5.3 % | 15 % |
A small sphere holds few atoms, so gaining or losing one moves the answer a lot; that is why the 4 Å curve is broad. But notice the last column. If the atoms were placed independently, the count in a sphere would be Poisson-distributed with relative width \(1/\sqrt{\langle N\rangle}\) — 43 % and 15 %. The measured widths are much narrower than that, and the gap widens as the probe grows.
This is not an error, and it is the most physical thing on the page. A liquid is nearly incompressible: pushing extra atoms into a region costs energy, so density fluctuations are strongly suppressed relative to independent placement. Formally the suppression factor tends to \(S(0)\), the \(k\to 0\) limit of the structure factor, which is proportional to the isothermal compressibility. So the width of this histogram is a thermodynamic measurement wearing a geometric disguise — and quoting it as "counting noise" would throw the physics away.
The practical lesson still holds: the probe radius is a resolution knob with a cost at both ends. Too small and the distribution is dominated by how few atoms fit inside; too large and a real interface is averaged across its own width and vanishes. Pick it from a physical length — the first minimum of \(g(r)\) if you mean "the first shell", or the expected interface thickness if you mean "which phase is this atom in".
One consequence surprises people: the mean local density around an atom is not exactly the bulk density. You are measuring from an atom, and atoms have neighbours, so you are sampling the \(g(r)\)-weighted density
with \(n(R)\) the coordination number from RDF, which that page shows how to compute in four lines. For argon \(n(8\,\text{Å}) = 44.7\), and dividing by the sphere volume \(\tfrac{4}{3}\pi 8^3 = 2145\) ų gives 0.0209 Å⁻³ against the 0.0210 measured here — two different computes agreeing. The local density approaches the bulk value only as \(R\) grows past the last correlation shell.
Computing both¶
import numpy as np
import molpy as mp
rng = np.random.default_rng(0)
xyz = rng.uniform(0.0, 20.0, size=(400, 3))
frame = mp.Frame()
frame["atoms"] = {"x": xyz[:, 0], "y": xyz[:, 1], "z": xyz[:, 2]}
frame.box = mp.Box.cubic(20.0)
LocalDensity takes frames and neighbor lists, and returns one
(num_neighbors, density) pair per frame — two arrays of length \(N\):
from molpy.compute import NeighborList, LocalDensity
nlist = NeighborList(cutoff=5.0)(frame)
(counts, density), = LocalDensity(r_max=5.0)([frame], [nlist])
print(counts.shape, density.shape) # -> (400,) (400,)
print(round(float(density.mean()), 4)) # -> 0.0507
Random points have no structure, so the mean local density here should be the bulk value \(400/20^3 = 0.05\) Å⁻³, and it is. That is a good habit: run a compute on an uncorrelated system first, where you know the answer.
Note that r_max and the neighbor list's cutoff are two separate numbers that
you must keep consistent. LocalDensity counts only pairs the list contains, so
a cutoff smaller than r_max silently undercounts and reports a density that is
too low. Set them equal unless you have a reason not to.
The optional diameter argument softens the hard edge of the counting sphere.
With the default 0.0 an atom is either in or out, and a particle drifting
across the boundary makes the density jump. With diameter = d, atoms near the
edge are counted with a weight that ramps linearly from 1 to 0 across a shell of
thickness \(d\) centred on r_max:
which reduces to plain counting when \(d = 0\). Use it when you care about the density of finite-sized particles rather than of points, or when a jumpy per-atom density is causing trouble downstream.
GaussianDensity takes frames alone and returns one (nx, ny, nz) grid per
frame:
from molpy.compute import GaussianDensity
grid, = GaussianDensity(nx=32, ny=32, nz=32, sigma=1.5)([frame])
print(grid.shape) # -> (32, 32, 32)
Grid spacing is the box edge divided by the resolution — here 20/32 = 0.63 Å. Keep \(\sigma\) at or above the spacing, or you are sampling a Gaussian narrower than your own grid and the field becomes a set of spikes.
One property of this kernel is worth knowing before you integrate anything from it. Each Gaussian is truncated at \(3\sigma\) and the remaining tail is not folded back in, so the grid integrates to 97.1 % of the particle count rather than 100 %:
The deficit is the fraction of a 3-D Gaussian lying beyond \(3\sigma\), so as long
as the grid actually resolves the Gaussian it is a fixed 2.9 %, whatever
\(\sigma\) and resolution you choose. Under-resolve it and you lose more: at
nx=16 with \(\sigma = 0.3\) Å the spacing is 1.25 Å, four times \(\sigma\), and
the integral falls to 344. That is harmless for visualization and for ratios,
but if you need absolute particle counts from the field, keep \(\sigma\) at or
above the spacing and divide by 0.971.
No figure for GaussianDensity yet — TODO
A density-field slice only says anything for an inhomogeneous system: a
slab, a droplet, a pore. The reference trajectory behind every figure in
these pages is bulk argon, which renders as featureless noise, so there is
nothing honest to plot. This page will get a figure when an interfacial
trajectory is added under scripts/docs_data/.
When it goes wrong¶
Every atom reports nearly the same density, and you expected two phases.
r_max is larger than the feature you are looking for. A 10 Å probe cannot see
a 5 Å interface.
The distribution is wide and lumpy, and it changes every frame.
r_max is too small — you are looking at counting noise. Widen it, or average
over frames.
LocalDensity values look systematically low near a boundary.
This is expected only if the box is non-periodic. With a periodic box the sphere
wraps and there is no edge effect; if you see one, check frame.box.
The Gaussian grid is mostly zeros with sharp dots. \(\sigma\) is smaller than the grid spacing. Raise \(\sigma\) or the resolution.
Densities from two systems disagree and you cannot see why.
Compare the probe radii before anything else. A local density without its
r_max (or \(\sigma\)) is not a reportable number.
Check yourself¶
- Scatter \(N\) points at random in a box and check that the mean
LocalDensityequals \(N/V\). Then halver_maxand confirm the mean is unchanged while the spread roughly doubles. - Sum a
GaussianDensitygrid over all voxels, multiply by the voxel volume, and check you get \(0.971 N\) whenever \(\sigma\) is at least the grid spacing. Then work out where the missing 2.9 % went. (Hint: the fraction of a 3-D Gaussian beyond \(3\sigma\).) - Predict which of
r_max = 3Å orr_max = 10Å gives the larger standard deviation, then measure it.
References¶
- V. Ramasubramani et al., Comput. Phys. Commun. 254, 107275 (2020) — the freud density kernels this API mirrors.
- M. P. Allen, D. J. Tildesley, Computer Simulation of Liquids, 2nd ed., Oxford (2017) — density profiles and interfacial averaging.
See also¶
- RDF — the same information averaged over all directions
- NeighborList — what
LocalDensityconsumes - Voronoi — a parameter-free alternative local volume
- API reference