Source code for MDAnalysis.lib.mdamath

# -*- Mode: python; tab-width: 4; indent-tabs-mode:nil; coding:utf-8 -*-
# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4
#
# MDAnalysis --- https://www.mdanalysis.org
# Copyright (c) 2006-2017 The MDAnalysis Development Team and contributors
# (see the file AUTHORS for the full list of names)
#
# Released under the Lesser GNU Public Licence, v2.1 or any higher version
#
# Please cite your use of MDAnalysis in published work:
#
# R. J. Gowers, M. Linke, J. Barnoud, T. J. E. Reddy, M. N. Melo, S. L. Seyler,
# D. L. Dotson, J. Domanski, S. Buchoux, I. M. Kenney, and O. Beckstein.
# MDAnalysis: A Python package for the rapid analysis of molecular dynamics
# simulations. In S. Benthall and S. Rostrup editors, Proceedings of the 15th
# Python in Science Conference, pages 102-109, Austin, TX, 2016. SciPy.
# doi: 10.25080/majora-629e541a-00e
#
# N. Michaud-Agrawal, E. J. Denning, T. B. Woolf, and O. Beckstein.
# MDAnalysis: A Toolkit for the Analysis of Molecular Dynamics Simulations.
# J. Comput. Chem. 32 (2011), 2319--2327, doi:10.1002/jcc.21787
#

"""
Mathematical helper functions --- :mod:`MDAnalysis.lib.mdamath`
===============================================================


Helper functions for common mathematical operations. Some of these functions
are written in C/cython for higher performance.

Linear algebra
--------------

.. autofunction:: normal
.. autofunction:: norm
.. autofunction:: pdot
.. autofunction:: pnorm
.. autofunction:: angle
.. autofunction:: dihedral
.. autofunction:: stp
.. autofunction:: sarrus_det
.. autofunction:: triclinic_box
.. autofunction:: triclinic_vectors
.. autofunction:: box_volume


Connectivity
------------

.. autofunction:: make_whole
.. autofunction:: find_fragments


.. versionadded:: 0.11.0
.. versionchanged: 1.0.0
   Unused function :func:`_angle()` has now been removed.

"""
import numpy as np

from ..exceptions import NoDataError
from . import util
from ._cutil import (make_whole, find_fragments, _sarrus_det_single,
                     _sarrus_det_multiple)
import numpy.typing as npt
from typing import Union

# geometric functions


[docs] def norm(v: npt.ArrayLike) -> float: r"""Calculate the norm of a vector v. .. math:: v = \sqrt{\mathbf{v}\cdot\mathbf{v}} This version is faster then numpy.linalg.norm because it only works for a single vector and therefore can skip a lot of the additional fuss linalg.norm does. Parameters ---------- v : array_like 1D array of shape (N) for a vector of length N Returns ------- float norm of the vector """ return np.sqrt(np.dot(v, v))
[docs] def normal(vec1: npt.ArrayLike, vec2: npt.ArrayLike) -> npt.NDArray: r"""Returns the unit vector normal to two vectors. .. math:: \hat{\mathbf{n}} = \frac{\mathbf{v}_1 \times \mathbf{v}_2}{|\mathbf{v}_1 \times \mathbf{v}_2|} If the two vectors are collinear, the vector :math:`\mathbf{0}` is returned. .. versionchanged:: 0.11.0 Moved into lib.mdamath """ # TODO: enable typing when https://github.com/python/mypy/issues/11347 done normal: npt.NDArray = np.cross(vec1, vec2) # type: ignore n = norm(normal) if n == 0.0: return normal # returns [0,0,0] instead of [nan,nan,nan] # ... could also use numpy.nan_to_num(normal/norm(normal)) return normal / n
[docs] def pdot(a: npt.NDArray, b: npt.NDArray) -> npt.NDArray: """Pairwise dot product. ``a`` must be the same shape as ``b``. Parameters ---------- a: :class:`numpy.ndarray` of shape (N, M) b: :class:`numpy.ndarray` of shape (N, M) Returns ------- :class:`numpy.ndarray` of shape (N,) """ return np.einsum('ij,ij->i', a, b)
[docs] def pnorm(a: npt.NDArray) -> npt.NDArray: """Euclidean norm of each vector in a matrix Parameters ---------- a: :class:`numpy.ndarray` of shape (N, M) Returns ------- :class:`numpy.ndarray` of shape (N,) """ return pdot(a, a)**0.5
[docs] def angle(a: npt.ArrayLike, b: npt.ArrayLike) -> float: """Returns the angle between two vectors in radians .. versionchanged:: 0.11.0 Moved into lib.mdamath .. versionchanged:: 1.0.0 Changed rounding-off code to use `np.clip()`. Values lower than -1.0 now return `np.pi` instead of `-np.pi` """ x = np.dot(a, b) / (norm(a) * norm(b)) # catch roundoffs that lead to nan otherwise x = np.clip(x, -1.0, 1.0) return np.arccos(x)
[docs] def stp(vec1: npt.ArrayLike, vec2: npt.ArrayLike, vec3: npt.ArrayLike) -> float: r"""Takes the scalar triple product of three vectors. Returns the volume *V* of the parallel epiped spanned by the three vectors .. math:: V = \mathbf{v}_3 \cdot (\mathbf{v}_1 \times \mathbf{v}_2) .. versionchanged:: 0.11.0 Moved into lib.mdamath """ # TODO: enable typing when https://github.com/python/mypy/issues/11347 done return np.dot(vec3, np.cross(vec1, vec2)) # type: ignore
[docs] def dihedral(ab: npt.ArrayLike, bc: npt.ArrayLike, cd: npt.ArrayLike) -> float: r"""Returns the dihedral angle in radians between vectors connecting A,B,C,D. The dihedral measures the rotation around bc:: ab A---->B \ bc _\' C---->D cd The dihedral angle is restricted to the range -π <= x <= π. .. versionadded:: 0.8 .. versionchanged:: 0.11.0 Moved into lib.mdamath """ x = angle(normal(ab, bc), normal(bc, cd)) return (x if stp(ab, bc, cd) <= 0.0 else -x)
[docs] def sarrus_det(matrix: npt.NDArray) -> Union[float, npt.NDArray]: """Computes the determinant of a 3x3 matrix according to the `rule of Sarrus`_. If an array of 3x3 matrices is supplied, determinants are computed per matrix and returned as an appropriately shaped numpy array. .. _rule of Sarrus: https://en.wikipedia.org/wiki/Rule_of_Sarrus Parameters ---------- matrix : numpy.ndarray An array of shape ``(..., 3, 3)`` with the 3x3 matrices residing in the last two dimensions. Returns ------- det : float or numpy.ndarray The determinant(s) of `matrix`. If ``matrix.shape == (3, 3)``, the determinant will be returned as a scalar. If ``matrix.shape == (..., 3, 3)``, the determinants will be returned as a :class:`numpy.ndarray` of shape ``(...,)`` and dtype ``numpy.float64``. Raises ------ ValueError: If `matrix` has less than two dimensions or its last two dimensions are not of shape ``(3, 3)``. .. versionadded:: 0.20.0 """ m = matrix.astype(np.float64) shape = m.shape ndim = m.ndim if ndim < 2 or shape[-2:] != (3, 3): raise ValueError("Invalid matrix shape: must be (3, 3) or (..., 3, 3), " "got {}.".format(shape)) if ndim == 2: return _sarrus_det_single(m) return _sarrus_det_multiple(m.reshape((-1, 3, 3))).reshape(shape[:-2])
[docs] def triclinic_box(x: npt.ArrayLike, y: npt.ArrayLike, z: npt.ArrayLike) -> npt.NDArray: """Convert the three triclinic box vectors to ``[lx, ly, lz, alpha, beta, gamma]``. If the resulting box is invalid, i.e., any box length is zero or negative, or any angle is outside the open interval ``(0, 180)``, a zero vector will be returned. All angles are in degrees and defined as follows: * ``alpha = angle(y,z)`` * ``beta = angle(x,z)`` * ``gamma = angle(x,y)`` Parameters ---------- x : array_like Array of shape ``(3,)`` representing the first box vector y : array_like Array of shape ``(3,)`` representing the second box vector z : array_like Array of shape ``(3,)`` representing the third box vector Returns ------- numpy.ndarray A numpy array of shape ``(6,)`` and dtype ``np.float32`` providing the unitcell dimensions in the same format as returned by :attr:`MDAnalysis.coordinates.timestep.Timestep.dimensions`:\n ``[lx, ly, lz, alpha, beta, gamma]``.\n Invalid boxes are returned as a zero vector. Note ---- Definition of angles: http://en.wikipedia.org/wiki/Lattice_constant See Also -------- :func:`~MDAnalysis.lib.mdamath.triclinic_vectors` .. versionchanged:: 0.20.0 Calculations are performed in double precision and invalid box vectors result in an all-zero box. """ x = np.asarray(x, dtype=np.float64) y = np.asarray(y, dtype=np.float64) z = np.asarray(z, dtype=np.float64) lx = norm(x) ly = norm(y) lz = norm(z) with np.errstate(invalid="ignore"): alpha = np.rad2deg(np.arccos(np.dot(y, z) / (ly * lz))) beta = np.rad2deg(np.arccos(np.dot(x, z) / (lx * lz))) gamma = np.rad2deg(np.arccos(np.dot(x, y) / (lx * ly))) box = np.array([lx, ly, lz, alpha, beta, gamma], dtype=np.float32) # Only positive edge lengths and angles in (0, 180) are allowed: if np.all(box > 0.0) and alpha < 180.0 and beta < 180.0 and gamma < 180.0: return box # invalid box, return zero vector: return np.zeros(6, dtype=np.float32)
[docs] def triclinic_vectors(dimensions: npt.ArrayLike, dtype: npt.DTypeLike = np.float32) -> npt.NDArray: """Convert ``[lx, ly, lz, alpha, beta, gamma]`` to a triclinic matrix representation. Original `code by Tsjerk Wassenaar`_ posted on the Gromacs mailinglist. If `dimensions` indicates a non-periodic system (i.e., all lengths are zero), zero vectors are returned. The same applies for invalid `dimensions`, i.e., any box length is zero or negative, or any angle is outside the open interval ``(0, 180)``. .. _code by Tsjerk Wassenaar: http://www.mail-archive.com/[email protected]/msg28032.html Parameters ---------- dimensions : array_like Unitcell dimensions provided in the same format as returned by :attr:`MDAnalysis.coordinates.timestep.Timestep.dimensions`:\n ``[lx, ly, lz, alpha, beta, gamma]``. dtype: numpy.dtype The data type of the returned box matrix. Returns ------- box_matrix : numpy.ndarray A numpy array of shape ``(3, 3)`` and dtype `dtype`, with ``box_matrix[0]`` containing the first, ``box_matrix[1]`` the second, and ``box_matrix[2]`` the third box vector. Notes ----- * The first vector is guaranteed to point along the x-axis, i.e., it has the form ``(lx, 0, 0)``. * The second vector is guaranteed to lie in the x/y-plane, i.e., its z-component is guaranteed to be zero. * If any box length is negative or zero, or if any box angle is zero, the box is treated as invalid and an all-zero-matrix is returned. .. versionchanged:: 0.7.6 Null-vectors are returned for non-periodic (or missing) unit cell. .. versionchanged:: 0.20.0 * Calculations are performed in double precision and zero vectors are also returned for invalid boxes. * Added optional output dtype parameter. """ dim = np.asarray(dimensions, dtype=np.float64) lx, ly, lz, alpha, beta, gamma = dim # Only positive edge lengths and angles in (0, 180) are allowed: if not (np.all(dim > 0.0) and alpha < 180.0 and beta < 180.0 and gamma < 180.0): # invalid box, return zero vectors: box_matrix = np.zeros((3, 3), dtype=dtype) # detect orthogonal boxes: elif alpha == beta == gamma == 90.0: # box is orthogonal, return a diagonal matrix: box_matrix = np.diag(dim[:3].astype(dtype, copy=False)) # we have a triclinic box: else: box_matrix = np.zeros((3, 3), dtype=np.float64) box_matrix[0, 0] = lx # Use exact trigonometric values for right angles: if alpha == 90.0: cos_alpha = 0.0 else: cos_alpha = np.cos(np.deg2rad(alpha)) if beta == 90.0: cos_beta = 0.0 else: cos_beta = np.cos(np.deg2rad(beta)) if gamma == 90.0: cos_gamma = 0.0 sin_gamma = 1.0 else: gamma = np.deg2rad(gamma) cos_gamma = np.cos(gamma) sin_gamma = np.sin(gamma) box_matrix[1, 0] = ly * cos_gamma box_matrix[1, 1] = ly * sin_gamma box_matrix[2, 0] = lz * cos_beta box_matrix[2, 1] = lz * (cos_alpha - cos_beta * cos_gamma) / sin_gamma box_matrix[2, 2] = np.sqrt(lz * lz - box_matrix[2, 0] ** 2 - box_matrix[2, 1] ** 2) # The discriminant of the above square root is only negative or zero for # triplets of box angles that lead to an invalid box (i.e., the sum of # any two angles is less than or equal to the third). # We don't need to explicitly test for np.nan here since checking for a # positive value already covers that. if box_matrix[2, 2] > 0.0: # all good, convert to correct dtype: box_matrix = box_matrix.astype(dtype, copy=False) else: # invalid box, return zero vectors: box_matrix = np.zeros((3, 3), dtype=dtype) return box_matrix
[docs] def box_volume(dimensions: npt.ArrayLike) -> float: """Return the volume of the unitcell described by `dimensions`. The volume is computed as the product of the box matrix trace, with the matrix obtained from :func:`triclinic_vectors`. If the box is invalid, i.e., any box length is zero or negative, or any angle is outside the open interval ``(0, 180)``, the resulting volume will be zero. Parameters ---------- dimensions : array_like Unitcell dimensions provided in the same format as returned by :attr:`MDAnalysis.coordinates.timestep.Timestep.dimensions`:\n ``[lx, ly, lz, alpha, beta, gamma]``. Returns ------- volume : float The volume of the unitcell. Will be zero for invalid boxes. .. versionchanged:: 0.20.0 Calculations are performed in double precision and zero is returned for invalid dimensions. """ dim = np.asarray(dimensions, dtype=np.float64) lx, ly, lz, alpha, beta, gamma = dim if alpha == beta == gamma == 90.0 and lx > 0 and ly > 0 and lz > 0: # valid orthogonal box, volume is the product of edge lengths: volume = lx * ly * lz else: # triclinic or invalid box, volume is trace product of box matrix # (invalid boxes are set to all zeros by triclinic_vectors): tri_vecs = triclinic_vectors(dim, dtype=np.float64) volume = tri_vecs[0, 0] * tri_vecs[1, 1] * tri_vecs[2, 2] return volume