# -*- coding: utf-8 -*-
# written by Ralf Biehl at the Forschungszentrum Jülich ,
# Jülich Center for Neutron Science 1 and Institute of Complex Systems 1
# Jscatter is a program to read, analyse and plot data
# Copyright (C) 2015-2024 Ralf Biehl
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""
This module contains tools for normal mode analysis of MDAnalysis universe.
The module is inspired by several modules:
- MMTK, Konrad Hinsen (python 2.7) http://dirac.cnrs-orleans.fr/MMTK.html
At ILL a version for the newer numpy (>1.18) is available https://code.ill.fr/scientific-software/mmtk
Unfortunately not available for Python 3 and will not be updated.
- mdtraj/nma module written by Carlos Xavier Hernández availible at https://github.com/mdtraj/nma
- Prody, Bahar group http://prody.csb.pitt.edu/.
"""
import numbers
from collections import defaultdict
from itertools import combinations
import MDAnalysis.core.groups
import numpy as np
import scipy.linalg as la
from scipy import constants as co
import MDAnalysis
from MDAnalysis.lib import distances
from .mda import getSurfaceVolumePoints
from .mda import scatteringUniverse
__all__ = ['NM', 'ANMA', 'vibNM', 'brownianNMdiag', 'explicitNM']
#: molar gas constant R = N_A * k_B in unit g*nm**2/ps**2/mol
Rgas = co.k * co.N_A * 1e-3 # for E_kT=R*T(293); R= 8.31 J/K/mol = 8.31 1e-3 * g*nm**2/ps**2/mol
eye = np.identity(3)
[docs]
class Mode(np.ndarray):
"""see __new__"""
def __new__(cls, mode, eigenvalue, weight):
"""
Single mode representing the result of a mode analysis as unweighted mode.
The mode represents an unweighted real space displacement that results after weighing in a
weigthed mode with norm ||=1 and orthogonality.
"""
x = np.asanyarray(mode).view(cls)
x._eigenvalue = eigenvalue
x._weight = weight
x._raw = None
x._rmsd = None
x._norm = la.norm(x)
return x
def __array_finalize__(self, obj):
if obj is None:
return
if hasattr(obj, 'attr'):
for attribut in obj.attr:
self.__dict__[attribut] = getattr(obj, attribut)
@property
def array(self):
"""As bare array"""
return self.view(np.ndarray)
@property
def norm(self):
"""Norm"""
return self._norm
@property
def eigenvalue(self):
"""Mode eigenvalue"""
return self._eigenvalue
@property
def weight(self):
"""Mode weights"""
return self._weight
@property
def raw(self):
"""Raw Mode as weighted orthogonal eigenmode with norm =1, """
if self._raw is None:
if isinstance(self.weight, np.ndarray):
self._raw = self * self.weight[:, None]
else:
self._raw = self
return self._raw
@property
def rmsd(self):
"""Root mean square displacement of mode in units A.
"""
if self._rmsd is None:
self._rmsd = (self**2).sum(axis=1).mean()**0.5
return self._rmsd
[docs]
def setattr(self, objekt, prepend='', keyadd='_'):
"""
Set (copy) attributes from objekt.
Parameters
----------
objekt : objekt with attr or dictionary
Can be a dictionary of names:value pairs like {'name':[1,2,3,7,9]}
If object has property attr the returned attributenames are copied.
prepend : string, default ''
Prepend this string to all attribute names.
keyadd : char, default='_'
If reserved attributes (T, mean, ..) are found the name is 'T'+keyadd
"""
if hasattr(objekt, 'attr'):
for attribut in objekt.attr:
try:
setattr(self, prepend + attribut, getattr(objekt, attribut))
except AttributeError:
self.comment.append('mapped ' + attribut + ' to ' + attribut + keyadd)
setattr(self, prepend + attribut + keyadd, getattr(objekt, attribut))
elif isinstance(objekt, dict):
for key in objekt:
try:
setattr(self, prepend + key, objekt[key])
except AttributeError:
self.comment.append('mapped ' + key + ' to ' + key + keyadd)
setattr(self, prepend + key + keyadd, objekt[key])
@property
def attr(self):
"""
Show specific attribute names as sorted list of attribute names.
"""
if hasattr(self, '__dict__'):
return sorted([key for key in self.__dict__ if key[0] != '_'])
else:
return []
def __repr__(self):
# hide that we have a ndarray subclass, just not to confuse people
return self.view(np.ndarray).__repr__()
[docs]
class NM(object):
# add these attributes to Mode in __getitem__
_addattributes = ['kTrmsd', 'kTrmsdNM']
def __init__(self, atomgroup, vdwradii=None, cutoff=13, rigidgroups=None):
r"""
Base for normal mode analysis.
An ANMA with fixed force constant (k_b=418 g/ps²/mol) within a cutoff distance of 13 A.
The simplest kind of normal mode analysis with fixed force constant and cutoff
e.g. for deformation of structures. Other NM are prefered and have more physical meaning of eigenvalues.
Some parameters here are included for later normal mode types.
Parameters
----------
atomgroup : MDAnalysis atomgroup
Atomgroup or residues for normal mode analysis
If residues, Cα atoms for amino acids and for others the atom closest to the center is choosen.
vdwradii : float, dict, default=3.8, unused in NM
vdWaals radius used for neighbor bond determination.
The default corresponds to Cα backbone distance.
If dict like js.data.vdwradii these radii are used for the specified atoms.
cutoff : float, default 13, unused in NM
cutoff for nonbonded neighbor determination with distance smaller than cutoff.
Bonds are determined as *d < f (R1+R2)* with fudge_factor f.
rigidgroups : AtomGroup or list of AtomGroups
Rigid group or groups of atoms in argument atomgroups.
Atoms in Atomgroup will get bonds to all others of strength `rigidfactor*k_b` with .rigidfactor=2
to make the group internaly more rigid.
e.g. `rigid = uni.select_atoms('protein and name CA')[:13].atoms`
to make the first 13 atoms at the N-terminus rigid.
Returns
-------
Normal Mode Analysis object : NM
Object can be indexed do get a specific Mode object representing real space displacements in unit A
as unweighted mode.
Trivial modes of translation and rotation are [1..5] while the first nontrivial mode is 6.
Examples
--------
See :py:class:`~.bio.nma.ANMA`
Attributes
----------
ag :
Atomgroup of atoms used for mode calculation. these are Cα or center atoms.
N as number of atoms.
u : MDA universe
Original MDA universe.
eigenvalues : array-like, shape (N,)
Eigenvalues :math:`\lambda_i` in decreasing order in units 1/ps.
eigenvectors : array-like, shape (3N, n_modes)
First n_ weighted eigenvectors according to eigenvalues.
max_mode : int
Maximum calculated mode of possible 3N modes.
If a mode number is larger the new modes are automatically calculated.
weights : array 3N
Weights used for NM calculation.
vdwradii : dict
Dict of used van der Waals radii.
bonded : set
Set of bonded atoms in ag with pairs of index ix.
non_bonded : set
Set of non bonded atoms in ag with pairs of index ix.
hessian : array 3Nx3N
Hessian matrix used for NM analysis.
k_b, k_nb : float
bonded and nonbonded force constants.
rigidfactor : float
Factor for rigid groups.
cutoff : float
Cutoff for neighbor determination in units A.
kTd, kTdisplacement : array Nx3
Displacements in thermal equillibrium at temperature of the universe.
See specific normal mode description for details.
"""
# indicate atom modes
self._isAtomMode = False
self.u = atomgroup.universe
self._determine_atomgroup(atomgroup)
if vdwradii is None:
vdwradii = 3.8 # unit A
if isinstance(vdwradii, numbers.Real):
# a default vdwradii for all atoms
vdwradii = dict.fromkeys(set(self.ag.atoms.types), vdwradii)
self.vdwradii = vdwradii
# maximal mode number calculated
self.max_mode = -1
# weights for normal modes, 1 is unweighted (all equal) as default
self.weights = 1
# this value reproduces reasonable protein frequency spectrum
self.k_b = 418
self.cutoff = cutoff # cutoff in unit A
self._bonded = set()
self._non_bonded = set()
self.rigidfactor = 2
self.rigidgroups = rigidgroups
self.hessian = None
self._eigenvalues = None
self._eigenvectors = None
# fudge factor for bond determination
self.fudge_factor = 0.55
def _determine_atomgroup(self, atomgroup):
# determine if residue or atom normal modes
if np.all([isinstance(a, MDAnalysis.core.groups.Atom) for a in atomgroup]) and \
atomgroup.atoms.n_atoms == atomgroup.residues.atoms.n_atoms:
# mode with all atoms included
# This is more for NM analysis of smaller ag e.g to do NM for an amino acid
ag_ix = list(atomgroup.ix)
self._isAtomMode = True
else:
# residues or single atom per residue
ag_ix = []
for res in atomgroup:
if isinstance(res, MDAnalysis.core.groups.Residue):
if 'CA' in res.atoms.names:
# for residues we use CA atoms,
ag_ix.append(res.atoms.select_atoms('name CA')[0].ix)
else:
# for non amino acids (no CA!) we take most centric atom
i = np.argmin(la.norm(res.atoms.positions - res.atoms.center_of_geometry(), axis=1))
ag_ix.append(res.atoms[i].ix)
elif isinstance(res, MDAnalysis.core.groups.Atom):
ainres = res.residue.atoms.intersection(atomgroup)
if ainres.n_atoms == 1:
# one atom in this residue as e.g. for a CA selection
ag_ix.append(res.ix)
else:
raise MDAnalysis.exceptions.SelectionError(
'Multiple atoms in a residue selected ', [a for a in ainres])
self._isAtomMode = False
# ix of mode ag atoms
self._ag_ix = ag_ix
# respective atoms
self.ag = self.u.atoms[ag_ix]
def __getitem__(self, i):
# allow lists
if isinstance(i, (list, tuple)):
return [self[j] for j in i]
elif isinstance(i, slice):
if i.stop is None:
stop = self.max_mode
elif i.stop < 0:
stop = max(0, 3*self.ag.n_atoms - 1 - i.stop)
else:
stop = i.stop
if stop > self.max_mode:
# to prevent iterative diagonalize and do it only once for largest
_ = self[stop]
return [self[n] for n in
range(i.start if i.start is not None else 0, stop, i.step if i.step is not None else 1)]
if np.max(i) >= 3*self.ag.n_atoms:
raise StopIteration(f'Requested eigenvalue indices are not valid. Valid range is [0, {3*self.ag.n_atoms}].')
elif np.max(i) > self.max_mode:
# update if needed in 2000er steps
# most proteins <2000 aa will be quite fast
self.max_mode = min(3*self.ag.n_atoms-1, (np.max(i)//2000+1)*2000)
self._set_hessian()
self._set_rigid()
self._diagonalize()
# default integer selection
mode = Mode(self.displacement(i), self.eigenvalues[i], self.weights)
for attr in self._addattributes:
try:
setattr(mode, attr, getattr(self, attr)(i))
except TypeError:
# its a list entry
setattr(mode, attr, getattr(self, attr)[i])
return mode
def _diagonalize(self):
"""
Solve the eigenvalue problem for the Hessian to get eigenvalues and eigenvectors.
"""
vals, vecs = la.eigh(self.hessian, subset_by_index=(0, self.max_mode))
self._eigenvalues = vals
self._eigenvectors = vecs
self.vars = 1 / self.eigenvalues
self.trace = self.vars.sum()
@property
def shape(self):
return self.eigenvalues.shape[0], self.ag.n_atoms, 3
[docs]
def displacement(self, i):
"""
Particle unweighted displacement in units A.
Parameters
----------
i : int
Returns
-------
array Mx3
"""
if isinstance(self.weights, np.ndarray):
return self.raw(i) / self.weights[:, None]
else:
return self.raw(i)
@property
def eigenvalues(self):
if self._eigenvalues is None:
_ = self[6]
return self._eigenvalues
@property
def eigenvectors(self):
if self._eigenvectors is None:
_ = self[6]
return self._eigenvectors
[docs]
def allatommode(self, m):
"""
Eigenmode for the system given a rigid residue constraint.
Rigid means that all residue atoms are rigid coupled to the atom of NM analysis basis atoms.
Parameters
----------
m : int
Mode number
Returns
-------
mode : Mode ndarray (n_atoms x 3)
Mode displacements extended to all atoms using the residue displacement.
For residue without contribution to the modes the displacement is zero.
"""
# Fill mode with available values from the selected eigenvector of the Hessian
assert isinstance(m, numbers.Integral), 'm should be integer'
if self._isAtomMode:
return self[m]
arr = np.zeros((self.u.atoms.n_atoms, 3))
mode = self[m]
for a in self.ag.atoms:
arr[a.residue.atoms.ix, :] = mode[self._ag_ix.index(a.ix)]
return arr
[docs]
def raw(self, i):
"""
Raw weighted modes with norm=1 and orthogonal to others modes.
"""
if i > self.max_mode:
_ = self[i]
return self.eigenvectors[:, i].reshape(self.ag.n_atoms, 3)
[docs]
def animate(self, modes, scale=5, n_steps=10, oneaftertheother=True, kT=False):
"""
Animate modes as a trajectory that can be viewed in Pymol/VMD/nglview or saved.
Parameters
----------
modes : list of integer
Mode numbers to animate
scale : float
Amplitude scale factor
n_steps : int
Number of time steps for a mode.
oneaftertheother : bool
Animate one mode after the other or all parallel.
kT : bool
Use kT displacements instead of raw weigthed modes.
Returns
-------
uni : MDAnalysis universe as trajectory
Use the view method to show this universe.
Examples
--------
The example demonstrates how modes can be shown in Pymol or nglview in a Jupyter notebook.
The trajectory can be saved as multimodel PDB file or other format.
The example animation below of a linear Arg α-helix shows the first 4 non-trivial bending modes.
Higher modes contain also twist modes which seem to be unrealistic. An improved forcefield that includes
dihedral potentials and more might be necessary to get more realistic deformations.
This demonstrates the limits of ANM modes.
::
import jscatter as js
uni=js.bio.scatteringUniverse(js.examples.datapath+'/arg61.pdb',addHydrogen=False)
u = uni.select_atoms("protein and name CA")
nm = js.bio.NM(u,cutoff=10)
moving = nm.animate([6,7,8,9], scale=10, kT=True, oneaftertheother=False) # as trajectory
# view all frames in pymol
# start playing pressing 'play' button in pymol
moving.view(viewer='pymol',frames='all')
# write to multimodel pdb file
moving.select_atoms('protein').write('movieatomstructure.pdb', frames='all')
# show in Jupyter notebook
import nglview as nv
w = nv.show_mdanalysis(moving.select_atoms('protein'))
w.add_representation("ball+stick", selection="not protein")
w
# # create png movie in pymol (next line in pymol commandline)
# mpng movie, 0, 0, mode=2
# # convert to animated gif using ImageMagic convert
# %system convert -delay 10 -loop 0 -resize 200x200 -layers optimize -dispose Background movie*.png ani.gif
.. image:: ../../examples/images/arg61_animation.gif
:align: center
:width: 50 %
:alt: arg61_animation
"""
if isinstance(modes, numbers.Integral):
modes = [modes]
original_positions = self.u.atoms.positions
coordinates = []
if oneaftertheother:
for mode in modes:
if kT:
scale = scale * np.sqrt(self.kT / self.eigenvalues[mode])
evec = self.allatommode(mode)
for t in np.r_[0:2*np.pi:1j * n_steps]:
coordinates.append(original_positions + scale * np.sin(t) * evec)
else:
evec = np.c_[[self.allatommode(mode) for mode in modes]]
if kT:
scale = np.r_[[scale * np.sqrt(self.kT / self.eigenvalues[mode]) for mode in modes]]
else:
scale = np.r_[[scale for mode in modes]]
for t in np.r_[0:2*np.pi:1j * n_steps]:
pos = original_positions + (np.sin(t) * scale[:, None, None] * evec).sum(axis=0)
coordinates.append(pos)
mr = np.stack(coordinates)
u = scatteringUniverse(self.u._topology, mr, in_memory=True)
return u
@property
def bonded(self):
"""
Bonded nearest neighbors.
"""
if not self._bonded:
_ = self.non_bonded
return self._bonded
@property
def non_bonded(self):
"""
Non-bonded nearest neighbors within cutoff.
"""
if not self._non_bonded:
pairs, dist = distances.self_capped_distance(self.ag.positions,
max_cutoff=self.cutoff,
min_cutoff=0.1,
box=None,
return_distances=True)
# translate pair index to atom index
atomtypes = self.ag.atoms.types
for (p, q), d in zip(pairs, dist):
if d < (self.vdwradii[atomtypes[p]] + self.vdwradii[atomtypes[q]]) * self.fudge_factor:
self._bonded.add((self.ag[p].ix, self.ag[q].ix))
else:
self._non_bonded.add((self.ag[p].ix, self.ag[q].ix))
return self._non_bonded
[docs]
def rmsd(self, i):
"""
Particle displacement from unweighted modes as rmsd=raw/weight in units A.
Parameters
----------
i : int
Returns
-------
float
"""
return (self.displacement(i)**2).sum(axis=1).mean()**0.5
def _set_hessian_mn(self, m, n, g):
"""Set the value of the Hessian for a given pair of atoms (m, n)"""
i, j = self._ag_ix.index(m), self._ag_ix.index(n)
dist = self.u.atoms[m].position - self.u.atoms[n].position
dist2 = np.dot(dist, dist)
super_el = np.outer(dist, dist) * (- g / dist2)
i3 = i * 3
i33 = i3 + 3
j3 = j * 3
j33 = j3 + 3
self.hessian[i3:i33, j3:j33] = super_el
self.hessian[j3:j33, i3:i33] = super_el
self.hessian[i3:i33, i3:i33] = self.hessian[i3:i33, i3:i33] - super_el
self.hessian[j3:j33, j3:j33] = self.hessian[j3:j33, j3:j33] - super_el
def _set_hessian(self):
"""
make hessian
"""
if self.hessian is None:
# Initialize Hessian Matrix
self.hessian = np.zeros((3 * self.ag.n_atoms,
3 * self.ag.n_atoms))
for i, j in self.non_bonded | self.bonded:
self._set_hessian_mn(i, j, self.k_b)
else:
pass
def _set_rigid(self):
if self.rigidgroups is None:
return
rigidgroups = self.rigidgroups
if isinstance(rigidgroups, MDAnalysis.core.groups.AtomGroup):
rigidgroups = [rigidgroups]
for rg in rigidgroups:
bonds = set(combinations(np.unique(self.ag.intersection(rg)), 2))
for bond in bonds:
self._set_hessian_mn(bond[0].ix, bond[1].ix, self.k_b * self.rigidfactor)
@property
def kT(self):
# R*T(293); R= 8.31 J/K/mol = 8.31 1e-3 * g*nm**2/ps**2/mol
return Rgas * self.u.temperature
[docs]
def kTdisplacement(self, i):
r"""
Displacements in thermal equillibrium at temperature of the universe
:math:`d_i = \sqrt{kT/k_i}\tilde{v}_i` in units A.
Returns
-------
array Nx3
"""
# internal units g/mol, nm, ps
return np.sqrt(self.kT / self.eigenvalues[i]) * self.displacement(i)
[docs]
def kTdisplacementNM(self, i):
r"""
Displacements in thermal equillibrium at temperature of the universe
:math:`d_i = \sqrt{kT/k_i}\tilde{v}_i` in units **nm**.
Returns
-------
array Nx3
"""
# internal units g/mol, nm, ps
return self.kTdisplacement(i) / 10
[docs]
def kTrmsd(self, i):
r"""
Root-mean-square displacement in thermal equillibrium at temperature of the universe
:math:`rmsd_i = \sqrt{kT/k_i}|\tilde{v}_i|` in units A.
Returns
-------
float
"""
return (self.kTdisplacement(i)**2).sum(axis=1).mean()**0.5
[docs]
def kTrmsdNM(self, i):
r"""
Root-mean-square displacement in thermal equillibrium at temperature of the universe
:math:`rmsd_i = \sqrt{kT/k_i}|\tilde{v}_i|` in units **nm**.
Returns
-------
float
"""
return self.kTrmsd(i) / 10
[docs]
class ANMA(NM):
# add these attributes to Mode in __getitem__
_addattributes = ['frequency', 'forceConstant', 'effectiveForceConstant', 'kTrmsd', 'kTrmsdNM']
def __init__(self, atomgroup, k_b=418, k_nb=None, vdwradii=None, cutoff=13, rigidgroups=None):
r"""
Anisotropic Network Model (ANM) normal mode.
Modes are **unweighted energetic elastic normal modes** as ANM modes in [1]_ [2]_.
Eigenvalues are effective force constants of the respective mode displacement vector.
- ANMA are typically coarse grained models using only Cα atoms.
- Bonded atoms are determined as :math:`(R_i +R_j) f_{bonded} < |r_i-r_j|` with van der Waals radii R
and position r of atoms i,j.
- :math:`f_{bonded}=0.55` is used for bond determination e.g along the backbone Ca atoms.
We use van der Waals radii of 3.8 A for Cα atoms.
- Non-bonded neighbors are within a cutoff distance but not bonded. 13A is the default as
described in [2]_. Non-bonded interactions are e.g hydrophobic attraction.
- Assuming equal masses ANMA mode vectors are the same as vibrational modes but scaled eigenvalues.
Parameters
----------
atomgroup : MDAnalysis atomgroup
Atomgroup or residues for normal mode analysis.
If residues, the Cα atoms for aminoacids and for others the atom closest to the geometric center is choosen.
vdwradii : float, dict, default=3.8
vdWaals radius used for neighbor bond determination.
The default corresponds to Cα backbone distance.
If dict like js.data.vdwradii these radii are used for the specified atoms.
cutoff : float, default 13
Cutoff for nonbonded neighbor determination with distance smaller than cutoff in units A.
See [2]_ for comparison.
k_b : float, default=418.
Bonded spring constant between atomgroup elements in units g/ps²/mol.
``418 g/ps²/mol = 694 pN/nm = 1(+-0.5) kcal/(molA²)`` as mentioned in [2]_
to reproduce a reasonable protein frequency spectrum.
k_nb : float, default=None
Non-bonded spring constant in same units as k_b.
If None k_nb = k_b.
rigidgroups : AtomGroup or list of AtomGroups
Rigid group or groups of atoms in argument atomgroups.
Atoms in Atomgroup will get bonds to all others of strength `rigidfactor*k_b` with .rigidfactor=2
to make the group internaly more rigid.
e.g. `rigid = uni.select_atoms('protein and name CA')[:13].atoms`
Returns
-------
Normal Mode Analysis object : ANMA
- Object can be indexed do get a specific Mode object representing real space displacements in unit A.
- Trivial modes of translation and rotation are [1..5] while the first nontrivial mode is 6.
Examples
--------
Usage
::
import jscatter as js
uni=js.bio.scatteringUniverse('3pgk')
u = uni.select_atoms("protein and name CA")
nm = js.bio.ANMA(u)
# get first nontrivial mode 6
nm6 = nm[6]
nm6.rmsd # mode rmsd displacement
Using normal modes to deform an atomic structure for animation, simulation or fit to other structural data
like SAXS/SANS formfactors.
::
import jscatter as js
uni=js.bio.scatteringUniverse('3pgk')
u = uni.select_atoms("protein and name CA")
nm = js.bio.ANMA(u)
original_positions = uni.atoms.positions.copy()
# move atoms along first nontrivial mode 6
uni.atoms.positions = original_positions + 5 * nm.allatommode(6) -1 * nm.allatommode(7)
# restore original position
uni.atoms.positions = original_positions
Attributes
----------
_ : See :class:`~jscatter.bio.nma.NM` for additional attributes.
Notes
-----
Unweighted normal modes are solutions of the equation of motion
.. math:: \ddot{r} = K(r-r_0)
neglecting the mass matrix M (using equal masses) to simplify
the equation compared to vibrational modes (see :class:`~jscatter.bio.nma.vibNM`) (with masses).
The force constant matrix :math:`K` describes the particle potential
at equillibrium positions :math:`r_0`.
ANMA assumes that only next neighbor interactions are relevant [2]_.
The result are modes equivalent to energetic modes as described by K. Hinsen [3]_.
References
----------
.. [1] Doruker P, Atilgan AR, Bahar I. Dynamics of proteins predicted by
molecular dynamics simulations and analytical approaches: Application to
a-amylase inhibitor. *Proteins* **2000** 40:512-524.
.. [2] Atilgan AR, Durrell SR, Jernigan RL, Demirel MC, Keskin O,
Bahar I. Anisotropy of fluctuation dynamics of proteins with an
elastic network model. *Biophys. J.* **2001** 80:505-515.
.. [3] Normal Mode Theory and Harmonic Potential Approximations.
In Normal Mode Analysis: Theory and Applications to Biological and Chemical Systems (pp. 1–16).
Hinsen, K. (2005).
https://doi.org/10.1201/9781420035070.ch1
"""
super(ANMA, self).__init__(atomgroup, vdwradii, cutoff, rigidgroups)
self.k_b = k_b
if k_nb is None:
k_nb = k_b
self.k_nb = k_nb
self.rigidgroups = rigidgroups
self.hessian = None
def _set_hessian_mn(self, m, n, g):
"""Set the value of the Hessian for a given pair of atoms (m, n)"""
i, j = self._ag_ix.index(m), self._ag_ix.index(n)
dist = self.u.atoms[m].position - self.u.atoms[n].position
dist2 = np.dot(dist, dist)
super_el = np.outer(dist, dist) * (- g / dist2)
i3 = i * 3
i33 = i3 + 3
j3 = j * 3
j33 = j3 + 3
self.hessian[i3:i33, j3:j33] = super_el
self.hessian[j3:j33, i3:i33] = super_el
self.hessian[i3:i33, i3:i33] = self.hessian[i3:i33, i3:i33] - super_el
self.hessian[j3:j33, j3:j33] = self.hessian[j3:j33, j3:j33] - super_el
def _set_hessian(self):
"""
make hessian and get modes
"""
if self.hessian is None:
# Initialize Hessian Matrix
self.hessian = np.zeros((3 * self.ag.n_atoms,
3 * self.ag.n_atoms))
# Add Non-Bonded Interactions to Matrix
for i, j in self.non_bonded:
self._set_hessian_mn(i, j, self.k_nb)
# Add Bonded Interactions to Matrix
for i, j in self.bonded:
self._set_hessian_mn(i, j, self.k_b)
else:
pass
[docs]
def forceConstant(self, i):
r"""
Effective force constant of a mode :math:`k_{i} = \omega^2` (= eigenvalue).
Parameters
----------
i : int
Mode number
Returns
-------
float
"""
if i > self.max_mode:
_ = self[i]
return self.eigenvalues[i]
[docs]
def frequency(self, i):
r"""
Frequency :math:`f_i = \omega_i/(2\pi)` according to eigenvalue :math:`\omega_i^2`
Unit is 1/ps.
Parameters
----------
i : int
Returns
-------
float
"""
if i > self.max_mode:
_ = self[i]
# according to [2]_ page 509 Vibrational frequencies
return self.forceConstant(i)**0.5
[docs]
def effectiveForceConstant(self, i):
r"""
Effective force constant :math:`k_{i} = \omega_i^2`
Equivalent force constant of a 1dim oscillator along the respective normal mode.
"""
return self.forceConstant(i)
#: shortcut for effectiveForceConstant
eFC = effectiveForceConstant
[docs]
class vibNM(NM):
# add these attributes to Mode in __getitem__
_addattributes = ['frequency', 'effectiveMass', 'effectiveForceConstant', 'kTrmsd', 'kTrmsdNM']
def __init__(self, atomgroup, k_b=418, k_nb=None, vdwradii=None, cutoff=13, rigidgroups=None):
r"""
Mass weighted vibrational normal modes like ANMA modes.
Modes are mass weighted elastic normal modes with eigenvalue :math:`\omega_i`
and frequency :math:`f_i = \omega^{1/2}/(2\pi)`.
See :py:func:`ANMA` for details.
Parameters
----------
atomgroup : MDAnalysis atomgroup
Atomgroup or residues for normal mode analysis
If residues the Cα atoms for aminoacids and for others the atom closest to the center is choosen.
vdwradii : float, dict, default=3.8
vdWaals radius used for neighbor bond determination.
The default corresponds to Cα backbone distance.
If dict like js.data.vdwradii these radii are used for the specified atoms.
cutoff : float, default 13
Cutoff for nonbonded neighbor determination with distance smaller than cutoff in units A.
See [2]_ for comparison.
k_b : float, default = 418.
Bonded spring constant between atomgroup elements in units g/ps²/mol.
``418 g/ps²/mol = 694 pN/nm = 1(+-0.5) kcal/(molA²)`` as mentioned in [2]_
to reproduce a reasonable protein frequency spectrum.
k_nb : float, default = None.
Non-bonded spring constant in same units as k_b. If None, ``k_nb = k_b``.
rigidgroups : AtomGroup or list of AtomGroups
Rigid group or groups of atoms in argument atomgroups.
Atoms in Atomgroup will get bonds to all others of strength `rigidfactor*k_b` with .rigidfactor=2
to make the group internaly more rigid.
e.g. `rigid = uni.select_atoms('protein and name CA')[:13].atoms`
Returns
-------
Normal Mode Analysis object : vibNM
Object can be indexed do get a specific Mode object representing real space displacements in unit A.
Trivial modes of translation and rotation are [1..5] while the first nontrivial mode is 6.
Examples
--------
::
import jscatter as js
uni=js.bio.scatteringUniverse('3pgk')
u = uni.select_atoms("protein and name CA")
nm = js.bio.vibNM(u)
# get first nontrivial mode 6
nm6 = nm[6]
nm6.rmsd # mode rmsd displacement
Attributes
----------
_ : See :class:`~jscatter.bio.nma.NM` for additional attributes.
Notes
-----
Mass weighted normal modes are solutions of the equation of motion
.. math:: M\ddot{r} = K(r-r_0)
with diagonal mass matrix :math:`M` and force constant matrix :math:`K`.
For coarse grained NM the mass of the coarse grains is relevant (e.g. residue based the residue mass).
In Mass weighted coordinates :math:`\tilde{r} = \sqrt{M}r` this leads to
.. math:: \ddot{\tilde{r}} = \tilde{K}(\tilde{r}-\tilde{r}_0)
using :math:`\tilde{K} = \sqrt{M}^{-1}K\sqrt{M}^{-1}`
The equation is solved by weighted eigenmodes :math:`\hat{v}_i` and eigenvalues
:math:`\omega_i^2`. Weighted eigenmodes :math:`\hat{v}_i` are orthogonal
:math:`\hat{v}_i \cdot \hat{v}_i= \delta_{i,j}` and :math:`|\hat{v}_i|=1`.
Unweighted modes :math:`v = \hat{v}_i/\sqrt{M}` are not orthogonal and :math:`|v_i|\neq 1`.
For a distortion with amplitude A along mode i from equillibrium the particles oscillates along the solution
:math:`r(t) = r_0 + A \hat{v}_i cos(\omega_i t + \phi_i)` with random phase :math:`\phi_i` [4]_.
Correlation of modes would fix the phase.
In equillibrium thermal motions induce fluctuations with amplitude
:math:`d_i = A\tilde{v}_i = \sqrt{kT/(m_a\omega_j^2)}\hat{v}_i`.
For a detailed description see [3]_-[4]_ .
References
----------
.. [1] Doruker P, Atilgan AR, Bahar I. Dynamics of proteins predicted by
molecular dynamics simulations and analytical approaches: Application to
a-amylase inhibitor. *Proteins* **2000** 40:512-524.
.. [2] Atilgan AR, Durrell SR, Jernigan RL, Demirel MC, Keskin O,
Bahar I. Anisotropy of fluctuation dynamics of proteins with an
elastic network model. *Biophys. J.* **2001** 80:505-515.
.. [3] Harmonicity in slow protein dynamics. Retrieved from
Hinsen, K., Petrescu, A.-J., Dellerue, S., Bellissent-Funel, M.-C., & Kneller, G. R. .
Chemical Physics, 261(1–2), 25–37. (2000) https://doi.org/10.1016/S0301-0104(00)00222-6
.. [4] Normal Mode Theory and Harmonic Potential Approximations.
In Normal Mode Analysis: Theory and Applications to Biological and Chemical Systems (pp. 1–16).
Hinsen, K. (2005).
https://doi.org/10.1201/9781420035070.ch1
"""
super(vibNM, self).__init__(atomgroup, vdwradii, cutoff, rigidgroups)
self.k_b = k_b
if k_nb is None:
k_nb = k_b
self.k_nb = k_nb
self.rigidgroups = rigidgroups
self.hessian = None
# weights are matrix like but masses are like diagonal matrix
self.weights = (atomgroup.masses**0.5)
def _set_hessian_mn(self, m, n, g):
"""Set the value of the Hessian for a given pair of atoms (m, n)"""
i, j = self._ag_ix.index(m), self._ag_ix.index(n)
dist = self.u.atoms[m].position - self.u.atoms[n].position
dist2 = np.dot(dist, dist)
super_el = np.outer(dist, dist) * (- g / dist2)
i3 = i * 3
i33 = i3 + 3
j3 = j * 3
j33 = j3 + 3
self.hessian[i3:i33, j3:j33] = super_el / self.weights[i] / self.weights[j]
self.hessian[j3:j33, i3:i33] = super_el / self.weights[i] / self.weights[j]
self.hessian[i3:i33, i3:i33] = self.hessian[i3:i33, i3:i33] - super_el / self.weights[i]**2
self.hessian[j3:j33, j3:j33] = self.hessian[j3:j33, j3:j33] - super_el / self.weights[j]**2
def _set_hessian(self):
"""
make hessian and get modes
"""
# Initialize Hessian Matrix
self.hessian = np.zeros((3 * self.ag.n_atoms, 3 * self.ag.n_atoms))
# Add Non-Bonded Interactions to Matrix
for i, j in self.non_bonded:
self._set_hessian_mn(i, j, self.k_nb)
# Add Bonded Interactions to Matrix
for i, j in self.bonded:
self._set_hessian_mn(i, j, self.k_b)
[docs]
def frequency(self, i):
r"""
Frequency :math:`f_i = \omega_i/(2\pi)` according to eigenvalue :math:`\omega_i^2`
Unit is 1/ps.
"""
if i > self.max_mode:
_ = self[i]
return np.abs(self.eigenvalues[i])**0.5/2/np.pi
[docs]
def effectiveMass(self, i):
r"""
Effective mass :math:`m_{eff} = |\hat{v}|^2/|v|^2` of a mode in atomic mass units g/mol.
Equivalent mass of a 1dim oscillator along the respective normal mode v.
"""
# eigenvector v
# v = self[i]
if isinstance(self.weights, np.ndarray):
v = self.raw(i)/self.weights[:, None]
else:
v = self.raw(i)
# mass weighted eigenvector (not needed in 3d for norm)
vm = self.eigenvectors[:, i]
effm = la.norm(vm)**2 / la.norm(v)**2
return effm
#: shortcut for effectiveMass
eM = effectiveMass
[docs]
def effectiveForceConstant(self, i):
r"""
Effective force constant :math:`k_{eff} = m_{eff} \omega_i^2 = m_{eff} (2\pi f_{i}^2)`
in units g/ps²/mol = 1.658 pN/nm.
Equivalent force constant of a 1dim oscillator along the respective normal mode.
"""
effk = self.effectiveMass(i) * self.eigenvalues[i]
return effk
#: shortcut for effectiveForceConstant
eFC = effectiveForceConstant
[docs]
class brownianNMdiag(vibNM):
# add these attributes to Mode in __getitem__
_addattributes = ['invRelaxTime', 'effectiveFriction', 'effectiveForceConstant', 'kTrmsd', 'kTrmsdNM']
def __init__(self, atomgroup, k_b=418, k_nb=None, f_d=1, vdwradii=None, cutoff=13, rigidgroups=None):
r"""
Friction weighted Brownian normal modes [3]_-[6]_ like ANMA modes [2]_.
Modes are friction weighted normal modes with eigenvalues
:math:`1/\tau` of relaxation time :math:`\tau`.
- The friction is :math:`f_{residue} = f_d n_{number of neighbors}`.
See :py:func:`ANMA` for details.
Parameters
----------
atomgroup : MDAnalysis atomgroup
Atomgroup or residues for normal mode analysis
If residues the Cα atoms for aminoacids and for others the atom closest to the center is choosen.
vdwradii : float, dict, default=3.8
vdWaals radius used for neighbor bond determination.
The default corresponds to Cα backbone distance.
If dict like js.data.vdwradii these radii are used for the specified atoms.
cutoff : float, default 13
Cutoff for nonbonded neighbor determination with distance smaller than cutoff in units A.
See ANMA and [2]_ for comparison.
k_b : float, default = 418.
Bonded spring constant in units g/ps²/mol.
``418 g/ps²/mol = 694 pN/nm = 1(+-0.5) kcal/(molA²)`` as mentioned in [2]_
to reproduce a reasonable protein frequency spectrum.
k_nb : float, default = None.
Non-bonded spring constant in same units as k_b. If None k_b is used.
f_d : float
Friction of residue with surrounding residue in units g/mol/ps.
In [3]_ values in a range 5000-23000 g/ps/mol (amu/ps) was deduced from MD simulation.
With an average residue weight of 107 g/mol reasonable values are in a range 50-230 g/ps/mol.
rigidgroups : AtomGroup or list of AtomGroups
Rigid group or groups of atoms in argument atomgroups.
Atoms in Atomgroup will get bonds to all others of strength `rigidfactor*k_b` with .rigidfactor=2
to make the group internaly more rigid.
e.g. `rigid = uni.select_atoms('protein and name CA')[:13].atoms`
Returns
-------
NM mode object : brownianNM
Object can be indexed do get a specific Mode object representing real space displacements in unit A.
Trivial modes of translation and rotation are [1..5] while the first nontrivial mode is 6.
Examples
--------
::
import jscatter as js
uni=js.bio.scatteringUniverse('3pgk')
nm = js.bio.brownianNMdiag(uni.residues, f_d=5000, cutoff=13)
# get first nontrivial mode 6
nm6 = nm[6]
nm6.rmsd # mode rmsd displacement
Attributes
----------
_ : See :class:`~jscatter.bio.nma.NM` for additional attributes.
f_d : float
Friction of residue with surrounding atoms in units g/mol/ps.
Notes
-----
Brownian normal modes are friction weighed normal modes as solution of the Smoluchowski equation [4]_.
In the Langevin equation (see [4]_ equ 20) for dominating friction the accelerating term is negligible
and the equation of motions simplifies to (neglecting the noise term)
.. math:: \gamma\dot{r} = K(r-r_0)
with friction matrix :math:`\gamma` and force constant matrix :math:`K`.
The equation :math:`\dot{r}=\gamma^{-1}Kr` is solved by the eigenmodes :math:`\hat{b}_i` and eigenvalues
:math:`\lambda_i` with characteristic exponentially decaying solutions.
:math:`\hat{b}_i` are normalized and build an orthogonal basis.
For a distortion with amplitude A along mode i from equillibrium positions :math:`r_0`
the particles return along the solutions :math:`r(t) = r_0 + A \hat{b}_i e^{-\lambda_it}`
with relaxation time :math:`\tau_i = 1/\lambda_i`.
In equillibrium thermal motions induce fluctuations with amplitude
:math:`e_j = A\hat{b}_i = \sqrt{kT/(\lambda_i\Gamma_i)}\hat{b}_i` with
effective friction :math:`\Gamma_i = \hat{b}_i^T \gamma \hat{b}_i`
For a detailed description see [3]_-[6]_.
References
----------
.. [1] Dynamics of proteins predicted by molecular dynamics simulations and analytical approaches:
Application to a-amylase inhibitor.
Doruker P, Atilgan AR, Bahar I., Proteins, 40:512-524 (2000).
.. [2] Anisotropy of fluctuation dynamics of proteins with an elastic network model.
Atilgan AR, Durrell SR, Jernigan RL, Demirel MC, Keskin O, Bahar I.
Biophys. J., 80:505-515 (2001).
.. [3] Harmonicity in slow protein dynamics. Retrieved from
Hinsen, K., Petrescu, A.-J., Dellerue, S., Bellissent-Funel, M.-C., & Kneller, G. R. .
Chemical Physics, 261(1–2), 25–37. (2000)
https://doi.org/10.1016/S0301-0104(00)00222-6
.. [4] Normal Mode Theory and Harmonic Potential Approximations.
In Normal Mode Analysis: Theory and Applications to Biological and Chemical Systems (pp. 1–16).
Hinsen, K. (2005).
https://doi.org/10.1201/9781420035070.ch1
.. [5] Quasielastic neutron scattering and relaxation processes in proteins:
analytical and simulation-based models.
G. Kneller
Phys. Chem. Chem. Phys., 2005, 7, 2641–2655, doi: 10.1039/b502040a
.. [6] Coarse-Grained Langevin Equation for Protein Dynamics:
Global Anisotropy and a Mode Approach to Local Complexity
J. Copperman and M. G. Guenza
J. Phys. Chem. B 2015, 119, 9195−9211, dx.doi.org/10.1021/jp509473z
"""
super(brownianNMdiag, self).__init__(atomgroup, vdwradii=vdwradii, cutoff=cutoff, rigidgroups=rigidgroups)
self.k_b = k_b
if k_nb is None:
k_nb = k_b
self.k_nb = k_nb
self.f_d = f_d
self.hessian = None
car = self.u.calphaCoarseGrainRadius
vdw = defaultdict(lambda: car, {'C': car})
# diagonal friction weight proportional to neighbor density
friction = np.zeros(self.ag.n_atoms)
for i, aix in enumerate(self._ag_ix):
friction[i] = np.sum([aix in bb for bb in self.non_bonded])
friction[i] += np.sum([aix in bb for bb in self.bonded])
friction[i] *= self.f_d
self.weights = friction**0.5
[docs]
def frequency(self, i):
"""Not implemented"""
raise NotImplementedError('Brownian mode have invRelaxTime.')
[docs]
def invRelaxTime(self, i):
"""
Inverse relaxation time of mode i.
"""
if i > self.max_mode:
_ = self[i]
return self.eigenvalues[i]
#: shortcut for invRelaxTime
iRT = invRelaxTime
[docs]
def effectiveFriction(self, i):
r"""
Effective friction :math:`\Gamma_i = \hat{b}_i^T \gamma \hat{b}_i` of mode i
for friction matrix :math:`\gamma` and eigenvectors :math:`\hat{b}_i`
"""
if i > self.max_mode:
_ = self[i]
friction = self.weights**2
raw = self.raw(i)
return np.einsum('ij,i,ij', raw, friction, raw)
#: shortcut for effectiveFriction
eF = effectiveFriction
[docs]
def effectiveForceConstant(self, i):
r"""
Effective force constant :math:`k_i = \lambda_i\Gamma_i` of mode i
for friction matrix :math:`\gamma` and eigenvectors :math:`\hat{b}_i` with eigenvalue :math:`\lambda_i`
with :py:meth:`~.jscatter.bio.brownianNMdiag.effectiveFriction`
:math:`\Gamma_i = \hat{b}_i^T \gamma \hat{b}_i` .
"""
return self.invRelaxTime(i) * self.effectiveFriction(i)
#: shortcut for effectiveForceConstant
eFC = effectiveForceConstant
[docs]
class explicitNM(NM):
# add these attributes to Mode in __getitem__
_addattributes = ['kTrmsd', 'kTrmsdNM']
def __init__(self, equillibrium, displaced, **kwargs):
"""
Fake modes from explicit given configurations as displacement vectors.
Modes as displacement in direction of explicit given displaced configurations.
Allows also to create a subset of modes as linear combination of other modes to highlight specific motions.
Parameters
----------
equillibrium : MDAnalysis universe atomgroup or residues
Universe in equillibrium position.
If residues, Cα atoms for amino acids and for others the atom closest to the center is choosen.
displaced : list of arrays with same shape as equillibrium positions
List of atom positions displaced from equillibrium positions defining ``v = [equ - displaced]``.
- mode vectors = v / rmsd(v)
- eigenvalues = 1/rmsd(v)
- kTdisplacement = v = [equ - displaced]
kwargs : float
Additional keyword arguments needed for models as ``invRelaxTime`` for ``intScatFuncOU``
as list with one value per fake mode.
Returns
-------
Normal Mode Analysis object : Modes
Object can be indexed do get a specific Mode object representing real space displacements in unit A.
Here we have no trivial modes and indexing starts from 0.
- kTdisplacement represent the displacement from equillibrium to displaced.
- kTrmsd the corresponding rmsd
- eigenvectors (displacements) have rmsd=1
- eigenvalues correspond to 1/rmsd
Examples
--------
In the example the calcium atoms are not moving as we select only protein Cα for modes.
Using a selection including calcium pins these to their neighbours as residues.
We create configurations of the Cα atoms from vibNM.
The source of the modes migth be from simulation or handmade by rotating specific bonds e.g. of a linker.
::
import jscatter as js
uni = js.bio.scatteringUniverse('3cln_h.pdb')
# create displaced configurations
# this an be also from simulation or different pdb structures with same number of atoms
nm = js.bio.vibNM(uni.select_atoms("protein and name CA"))
displaced = []
a=1
for i,j,k in [[a,a,0],[a,0,a],[0,a,a]]:
displaced.append(nm.ag.positions + i*nm.kTdisplacement(6) + j*nm.kTdisplacement(7) + k*nm.kTdisplacement(8))
# create explicit modes
newnm = js.bio.explicitNM(nm.ag, displaced, frequency=[1,1,1])
# compare the first mode to the previous ones
newnm.animate([0], scale=1).view(viewer='pymol',frames='all')
"""
super(explicitNM, self).__init__(equillibrium)
self.u = equillibrium.universe
self.equ = self.ag.positions
self.dis = displaced
# assert np.all([dis.shape == self.equ.shape for dis in self.dis]),\
# 'displaced modes need to have same shape as equilibrium atom group.'
self._raw = []
self._eigenvalues = np.ones(len(self.dis))
self._raw = np.zeros((len(self.dis),) + self.equ.shape)
for i, dis in enumerate(self.dis):
try:
dif = self.equ - dis
except ValueError:
dif = self.equ - dis[self._ag_ix]
# eigenvalue as rmsd
self._eigenvalues[i] = 1/(dif**2).sum(axis=1).mean()**0.5
# raw mode as dip with rmsd = 1A
self._raw[i] = dif * self._eigenvalues[i]
# maximal mode number calculated
self.max_mode = self._raw.shape[0]
# weights for normal modes, 1 is unweighted (all equal)
self.weights = 1
# additional args
self._addattributes = self._addattributes + list(kwargs.keys())
for k, v in kwargs.items():
if k == 'eigenvalues':
k = '_' + k
setattr(self, k, v)
def _diagonalize(self):
pass
def _set_hessian(self):
pass
[docs]
def raw(self, i):
"""
Raw weighted modes with norm=1. Maybe not orthogonal.
"""
return self._raw[i]
[docs]
def kTdisplacement(self, i):
r"""
Displacements in thermal equillibrium at temperature of the universe in unit A.
For fake modes the original displacements to *displaced* from equillibrium.
Returns
-------
array Nx3
"""
return self.displacement(i) / self.eigenvalues[i]