Shape¶
Once Cluster has told you which particles form an aggregate, the next question is what that aggregate looks like. Is the micelle spherical? Is the polymer coil swollen or collapsed? Is the protein elongated?
All of it comes from one object: the gyration tensor.
One tensor, four numbers¶
Take a cluster of \(N\) particles with centre \(\mathbf{r}_c\) and build the \(3\times3\) matrix of squared displacements about that centre:
Its trace is the familiar radius of gyration,
a single number for overall size. But throwing away everything except the trace throws away the shape, which lives in the three eigenvalues \(\lambda_1\le\lambda_2\le\lambda_3\). They are the squared extents along the cluster's own principal axes, so the object is a sphere when they are equal, a rod when one dominates, and a disc when one is small.
Three conventional combinations name those cases:
\(b\) is the asphericity (zero for a sphere), \(c\) the acylindricity (zero for anything with an axis of symmetry), and \(\kappa^2\) the relative shape anisotropy, which is bounded: 0 for a sphere or any arrangement with tetrahedral-or-higher symmetry, 1 for points on a line. A random-walk polymer coil, despite everyone's mental picture of a fuzzy ball, sits around \(\kappa^2 \approx 0.4\) — it is distinctly aspherical on any given snapshot, and only looks spherical after you average over orientations.
The inertia tensor is the mass-weighted sibling of \(S\). Same eigenvectors when all masses are equal; use it when you need real principal axes of rotation rather than geometry.
Testing it where the answer is known¶
An ideal random-walk chain of \(N\) bonds of length \(b\) has an exact result:
so \(R_g \sim N^{\nu}\) with \(\nu = 1/2\). That makes it the right thing to compute first.
Figure 1. Radius of gyration of ideal random-walk chains, 200 chains per
length. Three curves: \(\sqrt{\langle R_g^2\rangle}\) and \(\langle R_g\rangle\),
both measured with RadiusOfGyration, against the exact \(\sqrt{Nb^2/6}\). The
fitted slope of the first is \(\nu = 0.492\).
The measured \(\sqrt{\langle R_g^2\rangle}\) tracks the exact curve to within 2 %, and the fitted exponent is 0.492 against the true 0.5. That is the validation.
Now look at the \(\langle R_g\rangle\) curve, and at the fact that it sits systematically below the other two by 2–4 %. That is not an error, and it is worth understanding because it will bite you elsewhere:
The formula predicts the mean of the square. Averaging \(R_g\) itself and comparing against \(\sqrt{Nb^2/6}\) compares two different quantities, and by Jensen's inequality the mean of a square root is always the smaller one. Whenever a textbook gives you \(\langle X^2 \rangle\), average \(X^2\) — not \(X\).
For real polymers \(\nu\) is the headline number: \(\approx 0.588\) in a good solvent (self-avoiding), \(0.5\) at the theta point, \(1/3\) for a collapsed globule. Measuring it is how you tell which regime your simulation is in.
Computing it¶
Every descriptor here is per cluster, so the pipeline always starts with
Cluster, and the reference point matters: gyration takes the
geometric centre from ClusterCenters, while inertia and \(R_g\) take the
mass-weighted CenterOfMass. They are not interchangeable arguments.
import numpy as np
import molpy as mp
from molpy.compute import (
CenterOfMass, Cluster, ClusterCenters, GyrationTensor,
InertiaTensor, NeighborList, RadiusOfGyration,
)
rng = np.random.default_rng(0)
n_beads = 200
steps = rng.normal(0.0, 1.0 / np.sqrt(3.0), size=(n_beads, 3))
chain = np.cumsum(steps, axis=0) + 100.0
frame = mp.Frame()
frame["atoms"] = {"x": chain[:, 0], "y": chain[:, 1], "z": chain[:, 2]}
frame.box = mp.Box.cubic(200.0)
masses = np.full(n_beads, 12.011)
clusters = Cluster(min_cluster_size=5)([frame], [NeighborList(cutoff=2.5)(frame)])
centers = ClusterCenters()([frame], clusters)
com = CenterOfMass(masses)([frame], clusters)
rg = np.asarray(RadiusOfGyration(masses)([frame], clusters, com)[0])
tensor = np.asarray(GyrationTensor()([frame], clusters, centers)[0])
print(round(float(rg[0]), 2), tensor.shape) # -> 4.84 (1, 3, 3)
Predicted \(\sqrt{200/6} = 5.77\); a single chain fluctuates by tens of percent about that, which is exactly why Figure 1 averages 200 of them.
Now unpack the shape from the tensor rather than stopping at \(R_g\):
eigenvalues = np.sort(np.linalg.eigvalsh(tensor[0]))
b = eigenvalues[2] - 0.5 * (eigenvalues[0] + eigenvalues[1])
c = eigenvalues[1] - eigenvalues[0]
kappa2 = (b**2 + 0.75 * c**2) / eigenvalues.sum() ** 2
print(round(float(kappa2), 3)) # -> 0.217
\(\kappa^2 = 0.22\) for this particular chain: not spherical, and a long
way from a rod either. Single chains scatter widely around the
ensemble value of about 0.4, so quote \(\kappa^2\) as an average. Pass masses=None
anywhere above for purely geometric (unit-mass) descriptors.
InertiaTensor(masses)([frame], clusters, com) returns the mass-weighted
version with the same shape, for principal axes.
When it goes wrong¶
\(R_g\) is enormous — comparable to the box. The cluster straddles a periodic boundary and was not unwrapped. Cluster identification works under minimum image, but the gyration tensor is computed from raw coordinates about a centre, so half the molecule appears a box-length away. This is the single most common failure on this page.
Fix: pick one atom of the cluster as a seed and re-express every other atom as
the nearest periodic image of that seed — Box.diff_dr applies the minimum-image
convention (including non-cubic cells):
box = mp.Box.cubic(20.0)
split = np.array(
[[0.5, 10.0, 10.0], [1.0, 10.0, 10.0], [19.5, 10.0, 10.0], [19.0, 10.0, 10.0]]
)
seed = split[0]
joined = seed + box.diff_dr(split - seed)
print(round(float(np.ptp(split[:, 0])), 2)) # -> 19.0 (PBC-split)
print(round(float(np.ptp(joined[:, 0])), 2)) # -> 2.0 (one molecule again)
Four atoms spanning a 20 Å box look 19 Å across; after minimum-image relative to
the seed they are the 2 Å cluster they actually are. Do this per cluster,
using the labels from Cluster, before any shape descriptor.
\(\kappa^2 > 1\) or negative. An algebra slip: \(\kappa^2\) is normalized by \(R_g^4\), that is \((\lambda_1+\lambda_2+\lambda_3)^2\), not by \(R_g^2\).
A sphere gives \(\kappa^2\) well above 0. Check how many particles you have. Shape descriptors of small clusters are dominated by noise; a handful of random points is never isotropic.
Mass-weighted and geometric results differ and you cannot say which you have.
masses=None means unit mass. Report which convention you used — this is
routinely omitted and makes published \(R_g\) values incomparable.
\(R_g\) is fine but the eigenvectors jump between frames. Degenerate eigenvalues. For a near-spherical object the principal axes are ill-defined, and no code can fix that.
Check yourself¶
- Compute \(R_g\) for points on a sphere of radius \(R\): you should get \(R\sqrt{3/5}\) for a filled ball and \(R\) for a shell.
- Compute \(\kappa^2\) for points on a straight line (should be 1) and for the vertices of a regular tetrahedron (should be 0).
- Average \(R_g^2\) over many ideal chains, take the square root, and compare with \(\sqrt{Nb^2/6}\). Then average \(R_g\) instead and watch the answer drop.
References¶
- D. N. Theodorou, U. W. Suter, Macromolecules 18, 1206 (1985) — the gyration tensor, asphericity, and \(\kappa^2\).
- M. Rubinstein, R. H. Colby, Polymer Physics, Oxford (2003), ch. 2 — ideal chains, \(\nu\), and the scaling regimes.
See also¶
- Cluster — produces the aggregates measured here
- Decomposition — reducing many shape descriptors at once
- Order · API reference