Deformation descriptors#

Once you have a reference configuration and a current (deformed) configuration of the same atoms, you can quantify what happened to the structure on a per-atom basis. pyscal3 ships four standard descriptors:

descriptor

what it captures

reference

atomic_strain

full 3×3 Green–Lagrange strain tensor

Shimizu, Ogata, Li (2007)

von_mises_strain

scalar shear invariant of the strain

d2min

non-affine residual after best-fit affine map

Falk & Langer (1998)

slip_vector

mean displacement difference vs reference

Zimmerman et al. (2001)

Use them to colour MD snapshots, locate slip bands, identify shear transformation zones in glasses, etc.

Important: both the reference and the current configurations must have neighbours computed with the same cutoff. Atoms with fewer than three matched neighbours return NaN.

import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import pyscal3 as pyscal
from pyscal3.structures import make_crystal

1. Uniaxial tension on FCC#

Stretch the lattice by 5 % along \(x\). The strain tensor should give \(E_{xx} \approx (1.05^2 - 1)/2 = 0.0513\), all other components zero.

ref = make_crystal('fcc', lattice_constant=4.05, repetitions=(4,4,4))
cur = ref.copy()
F = np.diag([1.05, 1.0, 1.0])
cur.set_positions(ref.get_positions() @ F.T)
cur.set_cell(np.array(ref.cell) @ F.T, scale_atoms=False)

for a in (ref, cur):
    pyscal.find_neighbors(a, method='cutoff', cutoff=3.5)

E = pyscal.atomic_strain(cur, ref)
print('mean strain tensor (interior atom):')
print(E[len(cur)//2].round(4))
vm = pyscal.von_mises_strain(cur, ref)
print(f'\nvon Mises strain mean = {np.nanmean(vm):.4f}, std = {np.nanstd(vm):.2e}')
mean strain tensor (interior atom):
[[ 0.0513 -0.      0.    ]
 [-0.     -0.      0.    ]
 [ 0.      0.      0.    ]]

von Mises strain mean = 0.0513, std = 2.60e-16

Pure tension: a single non-zero diagonal component. The von Mises shear invariant is non-zero because uniaxial tension does produce shear in the deviatoric sense (volume-preserving comparison).

2. Simple shear#

Apply \(F_{xy} = \gamma\) with \(\gamma = 0.10\). Now we expect a strong shear contribution.

gamma = 0.10
ref = make_crystal('fcc', lattice_constant=4.05, repetitions=(4,4,4))
cur = ref.copy()
F = np.eye(3); F[0, 1] = gamma
cur.set_positions(ref.get_positions() @ F.T)
cur.set_cell(np.array(ref.cell) @ F.T, scale_atoms=False)

for a in (ref, cur):
    pyscal.find_neighbors(a, method='cutoff', cutoff=3.5)

E   = pyscal.atomic_strain(cur, ref)
vm  = pyscal.von_mises_strain(cur, ref)
d2  = pyscal.d2min(cur, ref)
slp = pyscal.slip_vector(cur, ref)

print('strain tensor (interior atom):')
print(E[len(cur)//2].round(4))
print(f'von Mises strain  : mean = {np.nanmean(vm):.4f}')
print(f'D\u00b2_min          : mean = {np.nanmean(d2):.2e}  (\u2248 0 for affine)')
print(f'slip vector       : mean = {np.nanmean(slp, axis=0).round(4)}')
strain tensor (interior atom):
[[-0.     0.05   0.   ]
 [ 0.05   0.005  0.   ]
 [ 0.     0.     0.   ]]
von Mises strain  : mean = 0.0502
D²_min          : mean = 4.32e-31  (≈ 0 for affine)
slip vector       : mean = [-0.  0.  0.]

Two key sanity checks:

  • \(D^2_{\min}\) is essentially zero — the deformation is purely affine, so the residual after fitting an affine map is numerical noise.

  • The slip vector points along \(x\) on average, as expected for shear in the \(xy\)-plane.

3. Adding a non-affine perturbation#

Now superimpose random Gaussian displacements on top of the shear. This is what a real shear-transformation zone in a glass looks like. The \(D^2_{\min}\) map should light up where atoms moved away from the affine background.

rng = np.random.default_rng(0)
ref = make_crystal('fcc', lattice_constant=4.05, repetitions=(6,6,6))
cur = ref.copy()
F = np.eye(3); F[0, 1] = 0.05
cur.set_positions(ref.get_positions() @ F.T)
cur.set_cell(np.array(ref.cell) @ F.T, scale_atoms=False)

# Add localised non-affine kicks in a slab around z = z_mid
pos = cur.get_positions()
z_mid = pos[:, 2].mean()
in_band = np.abs(pos[:, 2] - z_mid) < 2.5
pos[in_band] += rng.normal(scale=0.25, size=(in_band.sum(), 3))
cur.set_positions(pos)

for a in (ref, cur):
    pyscal.find_neighbors(a, method='cutoff', cutoff=3.5)
d2 = pyscal.d2min(cur, ref)
vm = pyscal.von_mises_strain(cur, ref)

fig = make_subplots(rows=1, cols=2,
                    subplot_titles=('D\u00b2_min', 'von Mises strain'))
fig.add_trace(go.Scatter(x=pos[:, 2], y=d2, mode='markers',
                         marker=dict(size=4, opacity=0.6, color=d2,
                                     colorscale='Viridis'),
                         showlegend=False), row=1, col=1)
fig.add_trace(go.Scatter(x=pos[:, 2], y=vm, mode='markers',
                         marker=dict(size=4, opacity=0.6, color=vm,
                                     colorscale='Plasma'),
                         showlegend=False), row=1, col=2)
fig.update_xaxes(title_text='z (\u00c5)', row=1, col=1)
fig.update_xaxes(title_text='z (\u00c5)', row=1, col=2)
fig.update_layout(template='simple_white', height=380,
                  title='Per-atom deformation metrics across the disordered band')
fig

Both metrics rise sharply inside the disordered slab. Note the complementary view: \(D^2_{\min}\) ignores any uniform shear (it sees only non-affine motion), while von Mises captures the local shear of the affine fit — they highlight overlapping but distinct populations.

4. 3D map of D\u00b2_min#

A scatter plot of the atoms in 3D, colour-coded by \(D^2_{\min}\), gives a direct visual answer to where the rearrangement happened.

fig = go.Figure(data=go.Scatter3d(
    x=pos[:, 0], y=pos[:, 1], z=pos[:, 2],
    mode='markers',
    marker=dict(size=2.5, color=d2, colorscale='Viridis',
                colorbar=dict(title='D\u00b2_min'), opacity=0.85)))
fig.update_layout(template='simple_white', height=520,
                  scene=dict(xaxis_title='x', yaxis_title='y', zaxis_title='z'),
                  title='Atoms coloured by non-affine displacement')
fig

5. Storage#

Each call writes its result back into atoms.arrays:

  • pyscal_strain — (N, 3, 3) Green–Lagrange tensor

  • pyscal_von_mises — (N,) scalar shear invariant

  • pyscal_d2min — (N,) non-affine residual

  • pyscal_slip_vector — (N, 3) average displacement difference

# ensure all four arrays are present
pyscal.atomic_strain(cur, ref)
pyscal.von_mises_strain(cur, ref)
pyscal.d2min(cur, ref)
pyscal.slip_vector(cur, ref)
for k in ('pyscal_strain', 'pyscal_von_mises', 'pyscal_d2min', 'pyscal_slip_vector'):
    print(f'  {k:25s} shape={cur.arrays[k].shape}')
  pyscal_strain             shape=(864, 3, 3)
  pyscal_von_mises          shape=(864,)
  pyscal_d2min              shape=(864,)
  pyscal_slip_vector        shape=(864, 3)

Take-aways#

  • Affine deformations → use strain / von Mises.

  • Plastic / non-affine motion (shear bands, dislocations, glassy rearrangements) → use D²_min.

  • Use slip_vector to identify directions of slip in dislocation cores.

References#

  1. M. L. Falk and J. S. Langer, Dynamics of viscoplastic deformation in amorphous solids, Phys. Rev. E 57, 7192 (1998).

  2. F. Shimizu, S. Ogata and J. Li, Theory of shear banding in metallic glasses and molecular dynamics calculations, Mater. Trans. 48, 2923 (2007).

  3. J. A. Zimmerman et al., Surface step effects on nanoindentation, Phys. Rev. Lett. 87, 165507 (2001).