Source code for dxtb._src.calculators.properties.vibration.analysis

# This file is part of dxtb.
#
# SPDX-Identifier: Apache-2.0
# Copyright (C) 2024 Grimme Group
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Vibrational Analysis: Frequencies
=================================

This module contains the calculation of vibrational frequencies and the
corresponding normal modes from the mass-weighted Hessian.
"""

from __future__ import annotations

import torch
from tad_mctc import storch
from tad_mctc.data.mass import ATOMIC_MASS
from tad_mctc.math import einsum
from tad_mctc.molecule.geometry import is_linear
from tad_mctc.molecule.property import inertia_moment, positions_rel_com

from dxtb._src.typing import Any, Literal, NoReturn, Tensor
from dxtb._src.utils.math import qr

from .result import BaseResult

__all__ = ["VibResult", "vib_analysis"]


LINDEP_THRESHOLD = 1e-7


[docs] class VibResult(BaseResult): """ Data from the vibrational analysis. - Vibrational frequencies. - Normal modes. """ __slots__ = ["_modes", "_modes_unit"] def __init__( self, freqs: Tensor, modes: Tensor, device: torch.device | None = None, dtype: torch.dtype | None = None, ) -> None: """ Initialize the vibrational analysis result. Parameters ---------- freqs : Tensor Vibrational frequencies in atomic units. modes : Tensor Normal modes (unitless). device : torch.device | None, optional Device of the tensors. If ``None``, the device of `freqs` is used. Defaults to ``None``. dtype : torch.dtype | None, optional Data type of the tensors. If ``None``, the data type of `freqs` is used. Defaults to ``None``. """ super().__init__( freqs=freqs, device=device if device is not None else freqs.device, dtype=dtype if dtype is not None else freqs.dtype, ) self._modes = modes self._modes_unit = None # intensities @property def modes(self) -> Tensor: return self._modes @modes.setter def modes(self, *_: Any) -> NoReturn: raise RuntimeError("Setting normal modes is not supported.") @property def modes_unit(self) -> None: return self._modes_unit @modes_unit.setter def modes_unit(self, *_: Any) -> None: raise RuntimeError("Normal modes are unitless.") # conversion
[docs] def to_unit(self, value: Literal["freqs", "modes"], unit: str) -> Tensor: """ Convert a value from one unit to another based on the converter dictionary. """ if value == "freqs": return self._convert(self.freqs, unit, self.converter_freqs) # TODO: Implement conversion for normal modes (required?) # if value == "modes": # return self._convert(self.modes, unit, self.converter_modes) raise ValueError(f"Unsupported value for conversion: {value}")
[docs] def use_common_units(self) -> None: """ Convert the frequencies and intensities to common units, that is, `cm^-1` for frequencies. """ self.freqs_unit = "cm^-1"
def _get_translational_modes(mass: Tensor): """Translational modes""" massp = storch.sqrt(mass) Tx = einsum("...m,x->...mx", massp, torch.tensor([1, 0, 0])) Ty = einsum("...m,y->...my", massp, torch.tensor([0, 1, 0])) Tz = einsum("...m,z->...mz", massp, torch.tensor([0, 0, 1])) return Tx.ravel(), Ty.ravel(), Tz.ravel() def _get_rotational_modes(mass: Tensor, positions: Tensor): mpos = positions_rel_com(mass, positions) im = inertia_moment(mass, mpos, pos_already_com=True) # Eigendecomposition yields the principal moments of inertia (w) # and the principal axes of rotation (paxes) of a molecule. _, paxes = storch.eighb(im) # make z-axis rotation vector with smallest moment of inertia # _ = torch.flip(_, [-1]) paxes = torch.flip(paxes, [-1]) ex, ey, ez = paxes.mT # rotational mode coords_rot_frame = mpos @ paxes # einsum("...ij,...jk->...ik", mpos, paxes) cx, cy, cz = coords_rot_frame.mT massp = storch.sqrt(mass) _massp = massp[..., :, None] _cx = cx[..., :, None] _cy = cy[..., :, None] _cz = cz[..., :, None] Rx = _massp * (_cy * ez - _cz * ey) Ry = _massp * (_cz * ex - _cx * ez) Rz = _massp * (_cx * ey - _cy * ex) return Rx.ravel(), Ry.ravel(), Rz.ravel()
[docs] def vib_analysis( numbers: Tensor, positions: Tensor, hessian: Tensor, project_translational: bool = True, project_rotational: bool = True, ) -> VibResult: """ Vibrational analysis yielding frequencies and normal modes from mass-weighted Hessian. - http://gaussian.com/vib/ - https://github.com/psi4/psi4/blob/master/psi4/driver/qcdb/vib.py - https://github.com/pyscf/pyscf/blob/master/pyscf/hessian/thermo.py Parameters ---------- numbers : Tensor Atomic numbers for all atoms in the system (shape: ``(..., nat)``). positions : Tensor Cartesian coordinates of all atoms in the system ``(..., nat, 3)``. hessian : Tensor Hessian matrix of shape ``(..., nat, 3, nat, 3)``. project_rotational : bool, optional Whether to project out rotational degrees of freedom. project_translational : bool, optional Whether to project out translational degrees of freedom. Returns ------- tuple[Tensor, Tensor] Frequencies of shape ``(..., nfreqs)`` and normal modes of shape ``(..., 3*nat, nfreqs)``. """ nat = numbers.shape[-1] if hessian.shape == (*numbers.shape[:-1], nat, 3, nat, 3): # reshape (nb, nat, 3, nat, 3) to (nb, nat*3, nat*3) hessian = hessian.reshape(*[*numbers.shape[:-1], *2 * [3 * nat]]) # Mass-weighting without reshaping: # (nb, nat, 3, nat, 3) * (nb, nat) * (nb, nat) -> (nb, nat, 3, nat, 3) # mhess = einsum("...pxqy,...p,...q->...pxqy", hessian, ism, ism) elif hessian.shape == (*numbers.shape[:-1], nat * 3, nat * 3): raise ValueError( f"The Hessian matrix must have either shape (..., nat, 3, nat, 3) " "or (..., nat*3, nat*3) for a system with `nat` atoms. The shape " f"of the Hessian matrix is {hessian.shape}, while the shape of the " f"atomic numbers is {numbers.shape}." ) mass = ATOMIC_MASS(device=hessian.device, dtype=hessian.dtype)[numbers] # 1/sqrt(m) of shape (..., nat) -> (..., nat*3) invsqrtmass = torch.repeat_interleave( storch.reciprocal(storch.sqrt(mass)), 3, dim=-1 ) # mass-weighted Hessian mhess = einsum("...p,...pq,...q->...pq", invsqrtmass, hessian, invsqrtmass) # symmetrize h = (mhess + mhess.transpose(-2, -1)) * 0.5 # TODO: More sophisticated checks for atom # TODO: Test batch TRspace = [] if project_translational is True and numbers.shape[-1] > 1: TRspace.extend(_get_translational_modes(mass)) if project_rotational is True and numbers.shape[-1] > 1: if (is_linear(numbers, positions) == True).all(): TRspace.extend(_get_rotational_modes(mass, positions)[:-1]) else: TRspace.extend(_get_rotational_modes(mass, positions)) if len(TRspace) > 0: TRspace = torch.vstack(TRspace) # create orthogonal basis (q) for the modes q, _ = qr(TRspace.T) # The special projection matrix P=I-QQ^T is crucial for isolating # specific subspaces of interest in a high-dimensional space by # projecting vectors onto the subspace orthogonal to the column space # of QQ. In the context of vibrational analysis, the projection matrix # P is used to remove translational and rotational modes from # consideration, focusing the analysis on the true vibrational modes of # a molecular system. qqT = q @ q.mT # einsum("...ij,...kj->...ik", q, q) P = torch.eye(*[3 * numbers.shape[-1]]) - qqT w, v = storch.eighb(P) bvec = v[..., :, w > LINDEP_THRESHOLD] if bvec.shape[-1] == 0: raise RuntimeError( "The projection matrix for transformation to internal " f"coordinates appears to be empty (shape: {bvec.shape}) " "This is either caused by linear dependencies in the " "projection matrix or faulty geometry detection." ) # transform Hessian in mass-weighted cartesian coordinates (MWC) to new # Hessian in internal coordinates (INT): h_INT = bvec.T @ h_MVC @ bvec h = bvec.mT @ h @ bvec # einsum("...ji,...jk,...kl->...il", b, h, b) # eigendecomposition of Hessian yields force constants (not # frequencies!) and normal modes of vibration force_const_au, _mode = storch.eighb(h) mode = bvec @ _mode else: force_const_au, mode = storch.eighb(h) # Instead of calculating and diagonalizing the mass-weighted Hessian, # one could also solve the equivalent general eigenvalue problem Hv=Mve # This also directly un-mass-weights the normal modes. # # mass_mat = torch.diag_embed(masses.repeat_interleave(3, dim=-1)) # evals, evecs = eighb(a=h, b=mass_mat) # Vibrational frequencies ω = √λ with some additional logic for # handling possible negative eigenvalues. Note that we are not dividing # by 2π (ω = √λ / 2π) in order to immediately get frequencies in # Hartree: E = hbar * ω with hbar = 1 in atomic units. Dividing by 2π # effectively converts from angular frequency (ω) to the frequency in # cycles per second (ν, Hz), which would require the following # conversion to cm^-1: 1e-2 / units.CODATA.c / units.AU2SECOND. sgn = torch.sign(force_const_au) freqs_au = torch.sqrt(torch.abs(force_const_au)) * sgn # un-mass-weight the normal modes mode_au = einsum("...i,...ij->...ij", invsqrtmass, mode) return VibResult(freqs_au, mode_au)
# TODO: Remove this function after checking batched version def project_freqs( freqs: Tensor, modes: Tensor, is_linear: Tensor ) -> tuple[Tensor, Tensor]: """ Project out rotational and translational degrees of freedom, i.e., the 5 or 6 lowest eigenvalues (frequencies). Parameters ---------- freqs : Tensor Vibrational frequencies modes : Tensor Normal modes (nat * 3, nfreqs) is_linear : Tensor Whether the molecule is linear. Returns ------- tuple[Tensor, Tensor] Projected frequencies and normal modes """ skip = torch.where(is_linear, 5, 6) # Non-batched version if freqs.ndim == 1: return freqs[skip:], modes[:, skip:] # Batched version from tad_mctc.batch import deflate, pack projected_freqs = [] projected_modes = [] for i in range(freqs.size(0)): skip = 5 if is_linear[i] else 6 # deflating removes padding that can mess up the shapes # Example: LiH and H2O # (o: projected out, x: actual value, p: padding) # # Input tensors (batched): # LiH [o, o, o, o, o, x, p, p, p] # H2O [o, o, o, o, o, o, x, x, x] # # After projection we obtain: # LiH [x, p, p, p] (5->end) # H2O [x, x, x] (6->end) # # The final packing yields an extra dimension: # LiH [x, p, p, p] # H2O [x, x, x, p] -> increased size by one! projected_freqs.append(deflate(freqs[i, skip:])) projected_modes.append(deflate(modes[i, :, skip:])) return pack(projected_freqs), pack(projected_modes)