Skip to content

kups.observables.stress

Stress tensor calculations via the virial theorem.

Stress is the symmetric (3, 3) tensor

σ = -1/V sym[Σ_i r_i ⊗ ∂U/∂r_i + h^T · ∂U/∂h]

Only the lower-triangular entries of ∂U/∂h are stored -- the parameter degrees of freedom of a lower-triangular cell h -- so the full 3×3 cell virial is never materialized; the upper triangle of σ is filled by symmetry.

Per-axis periodicity is honoured: components touching a non-periodic (vacuum/bounding-box) axis are zeroed, so an isolated cluster has zero stress and a slab keeps only its in-plane block. The volume divisor is the full cell volume |det h| (LAMMPS convention: a slab's stress is diluted by its vacuum extent).

IsMolecularVirialParticles

Bases: HasPositions, HasGroupIndex, HasSystemIndex, Protocol

Particles with position gradients, group and system assignment.

Source code in src/kups/observables/stress.py
@runtime_checkable
class IsMolecularVirialParticles(HasPositions, HasGroupIndex, HasSystemIndex, Protocol):
    """Particles with position gradients, group and system assignment."""

    @property
    def position_gradients(self) -> Array: ...

IsVirialParticles

Bases: HasPositions, HasSystemIndex, Protocol

Particles with position gradients ∂U/∂r.

Source code in src/kups/observables/stress.py
@runtime_checkable
class IsVirialParticles(HasPositions, HasSystemIndex, Protocol):
    """Particles with position gradients ∂U/∂r."""

    @property
    def position_gradients(self) -> Array: ...

IsVirialSystems

Bases: HasCell[AnyPeriodicity], Protocol

Systems with a cell (any periodicity) and cell gradients ∂U/∂h (stored lower-triangular).

Source code in src/kups/observables/stress.py
@runtime_checkable
class IsVirialSystems(HasCell[AnyPeriodicity], Protocol):
    """Systems with a cell (any periodicity) and cell gradients ∂U/∂h
    (stored lower-triangular)."""

    @property
    def cell_gradients(self) -> Cell[AnyPeriodicity]: ...

molecular_stress_via_virial_theorem(particles, groups, systems)

Compute molecular virial stress tensor (RASPA convention).

Parameters:

Name Type Description Default
particles Table[ParticleId, IsMolecularVirialParticles]

Per-particle positions, group/system index, and gradients.

required
groups Table[GroupId, HasSystemIndex]

Per-group system assignment.

required
systems Table[SystemId, IsVirialSystems]

Per-system cell and cell gradients (lower-triangular).

required

Returns:

Type Description
Table[SystemId, Array]

Symmetric stress tensor per system, shape (n_systems, 3, 3).

Source code in src/kups/observables/stress.py
def molecular_stress_via_virial_theorem(
    particles: Table[ParticleId, IsMolecularVirialParticles],
    groups: Table[GroupId, HasSystemIndex],
    systems: Table[SystemId, IsVirialSystems],
) -> Table[SystemId, Array]:
    """Compute molecular virial stress tensor (RASPA convention).

    Args:
        particles: Per-particle positions, group/system index, and gradients.
        groups: Per-group system assignment.
        systems: Per-system cell and cell gradients (lower-triangular).

    Returns:
        Symmetric stress tensor per system, shape ``(n_systems, 3, 3)``.
    """
    group_cells = systems[groups.data.system].cell
    cell = systems.data.cell
    stress = _molecular_stress_via_virial_theorem(
        particles.data.position_gradients,
        cell.frame.vectors_gradient(systems.data.cell_gradients.frame),
        particles.data.positions,
        particles.data.group,
        group_cells,
        particles.data.system,
        cell,
    )
    return Table(systems.keys, stress)

stress_via_virial_theorem(particles, systems)

Compute atomic-level virial stress tensor.

Parameters:

Name Type Description Default
particles Table[ParticleId, IsVirialParticles]

Per-particle positions, system index, and position gradients.

required
systems Table[SystemId, IsVirialSystems]

Per-system cell and cell gradients (lower-triangular).

required

Returns:

Type Description
Table[SystemId, Array]

Symmetric stress tensor per system, shape (n_systems, 3, 3).

Source code in src/kups/observables/stress.py
def stress_via_virial_theorem(
    particles: Table[ParticleId, IsVirialParticles],
    systems: Table[SystemId, IsVirialSystems],
) -> Table[SystemId, Array]:
    """Compute atomic-level virial stress tensor.

    Args:
        particles: Per-particle positions, system index, and position gradients.
        systems: Per-system cell and cell gradients (lower-triangular).

    Returns:
        Symmetric stress tensor per system, shape ``(n_systems, 3, 3)``.
    """
    cell = systems.data.cell
    stress = _stress_via_virial_theorem(
        particles.data.position_gradients,
        cell.frame.vectors_gradient(systems.data.cell_gradients.frame),
        particles.data.positions,
        cell,
        particles.data.system,
    )
    return Table(systems.keys, stress)

total_lattice_gradient(positions, position_gradients, cell, partial_lattice_gradient, system)

Total lattice gradient ∂E/∂h|_r + h⁻ᵀ·Σ_i r_i ⊗ ∂E/∂r_i, in frame parameters.

A potential reports the partial gradient ∂E/∂h|_r, taken at fixed Cartesian positions. Variable-cell relaxation needs the total derivative, with atoms riding the cell at fixed fractional coordinates; this adds the position-virial term h⁻ᵀ·Σ_i r_i ⊗ ∂E/∂r_i. Stress is unchanged either way.

Non-periodic axes carry no atoms -- a slab/vacuum basis vector is a bounding-box edge, not a translation -- so their coupling rows are dropped via the cell's periodic mask.

Parameters:

Name Type Description Default
positions Array

Real-space positions r_i, shape (n, 3).

required
position_gradients Array

∂E/∂r_i, shape (n, 3).

required
cell Table[SystemId, C]

Per-system cells h (supply h⁻¹, the Jacobian, and per-axis periodicity); fixes the returned cell type.

required
partial_lattice_gradient Table[SystemId, C]

∂E/∂h|_r as per-system gradient cells.

required
system Index[SystemId]

Per-particle system index, replicating cell to particles and summing the position virial per system.

required

Returns:

Type Description
Table[SystemId, C]

Total lattice gradient as Table[SystemId, C] of cell's type.

Source code in src/kups/observables/stress.py
def total_lattice_gradient[C: Cell[AnyPeriodicity]](
    positions: Array,
    position_gradients: Array,
    cell: Table[SystemId, C],
    partial_lattice_gradient: Table[SystemId, C],
    system: Index[SystemId],
) -> Table[SystemId, C]:
    """Total lattice gradient ``∂E/∂h|_r + h⁻ᵀ·Σ_i r_i ⊗ ∂E/∂r_i``, in frame parameters.

    A potential reports the *partial* gradient ``∂E/∂h|_r``, taken at fixed
    Cartesian positions. Variable-cell relaxation needs the *total* derivative,
    with atoms riding the cell at fixed fractional coordinates; this adds the
    position-virial term ``h⁻ᵀ·Σ_i r_i ⊗ ∂E/∂r_i``. Stress is unchanged either way.

    Non-periodic axes carry no atoms -- a slab/vacuum basis vector is a
    bounding-box edge, not a translation -- so their coupling rows are dropped via
    the cell's [`periodic`][kups.core.cell.Cell] mask.

    Args:
        positions: Real-space positions ``r_i``, shape ``(n, 3)``.
        position_gradients: ``∂E/∂r_i``, shape ``(n, 3)``.
        cell: Per-system cells ``h`` (supply ``h⁻¹``, the Jacobian, and per-axis
            periodicity); fixes the returned cell type.
        partial_lattice_gradient: ``∂E/∂h|_r`` as per-system gradient cells.
        system: Per-particle system index, replicating ``cell`` to particles and
            summing the position virial per system.

    Returns:
        Total lattice gradient as ``Table[SystemId, C]`` of ``cell``'s type.
    """

    @Table.transform
    def compose_gradient(cell: C, coupling: Array, partial: C) -> C:
        # A non-periodic basis vector is a bounding-box edge, not a translation,
        # so its row carries no atoms -- drop the coupling there.
        coupling = coupling * jnp.array(cell.periodic)[:, None]
        coupling_gradient = cell.frame.parameter_gradient(coupling)
        return bind(partial, lambda c: c.frame).apply(
            lambda f: tree_map(jnp.add, f, coupling_gradient)
        )

    outer = positions[:, :, None] * position_gradients[:, None, :]  # r_i ⊗ ∂E/∂r_i
    coupling = system.sum_over(cell[system].inverse_vectors.mT @ outer)
    return compose_gradient(cell, coupling, partial_lattice_gradient)