Source code for pysisyphus.irc.Instanton

# [1] https://doi.org/10.1021/acs.jctc.8b00068
#     Dual-Level Approach to Instanton Theory
#     Meisner, Kästner 2018
# [2] https://doi.org/10.1021/ct100658y
#     Locating Instantons in Many Degrees of Freedom
#     Rommel, Goumans, Kästner, 2011
# [3] https://aip.scitation.org/doi/full/10.1063/1.4932362
#     Ring-polymer instanton theory of electron transofer in the
#     nonadiabatic limit
#     Richardson, 2015
import logging

import numpy as np
import scipy as sp

from pysisyphus.Geometry import Geometry
from pysisyphus.helpers import align_coords, get_coords_diffs
from pysisyphus.helpers_pure import eigval_to_wavenumber


[docs] def T_crossover_from_eigval(eigval): nu = eigval_to_wavenumber(eigval) # in cm⁻¹ nu_m = 100 * abs(nu) # in m⁻¹ freq = nu_m * sp.constants.speed_of_light # In papers the crossover temperature is usually defined with the angular # frequency nu*2*pi. When using the "normal" frequency we don't have to # divide by 2*pi. Both approaches obviously yield the same T_c. # ang_freq = freq * 2 * np.pi # T_c_ = sp.constants.hbar * ang_freq / (sp.constants.Boltzmann * 2 * np.pi) T_c = sp.constants.hbar * freq / (sp.constants.Boltzmann) return T_c
[docs] def T_crossover_from_ts(ts_geom): mw_hessian = ts_geom.mw_hessian proj_hessian = ts_geom.eckart_projection(mw_hessian, full=True) eigvals, eigvecs = np.linalg.eigh(proj_hessian) T_c = T_crossover_from_eigval(eigvals[0]) return T_c
logger = logging.getLogger("pysis.instanton") logger.setLevel(logging.DEBUG) file_handler = logging.FileHandler("instanton.log", mode="w", delay=True) logger.addHandler(file_handler)
[docs] def log_progress(val, key, i): logger.debug(f"Calculated {key} at image {i}") return val
[docs] class Instanton: def __init__(self, images, calc_getter, T): self.images = images self.calc_getter = calc_getter for image in self.images: image.set_calculator(calc_getter()) self.T = T # Pre-calculate action prefactors for the given temperature beta = 1 / (sp.constants.Boltzmann * self.T) # Joule beta_hbar = beta * sp.constants.hbar # seconds beta_hbar_fs = beta_hbar * 1e15 # fs self.beta_hbar = beta_hbar_fs self.P_over_beta_hbar = self.P / self.beta_hbar self.P_bh = self.P_over_beta_hbar """The Instanton is periodic: the first image is connected to the last image. At k=0 the index k-1 will be -1, which points to the last image. Below we pre-calculate some indices (assuming N images). unshifted indices ks: k = {0, 1, .. , N-1} shifted indices ksm1: k-1 = {-1, 0, 1, .. , N-2} shifted indices ksp1: k+1 = {1, 2, .. , N-1, 0} """ self.ks = np.arange(self.P) self.ksm1 = self.ks - 1 self.ksp1 = self.ks + 1 self.ksp1[-1] = 0 # k+1 for the last image points to the first image self.coord_type = "mwcartesian" self.internal = None @property def P(self): return len(self.images)
[docs] @classmethod def from_ts(cls, ts_geom, P, dr=0.4, delta_T=25, cart_hessian=None, **kwargs): assert ts_geom.coord_type == "mwcartesian" atoms = ts_geom.atoms ts_coords = ts_geom.coords if cart_hessian is None: mw_hessian = ts_geom.mw_hessian else: mw_hessian = ts_geom.mass_weigh_hessian(cart_hessian) proj_hessian = ts_geom.eckart_projection(mw_hessian, full=True) eigvals, eigvecs = np.linalg.eigh(proj_hessian) # Use crossover temperature with a little offset (delta_T) if no T is given. try: kwargs["T"] except KeyError: T_c = T_crossover_from_eigval(eigvals[0]) kwargs["T"] = T_c - delta_T imag_mode = eigvecs[:, 0] cosines = np.cos((np.arange(P) + 1 - 0.5) / P * np.pi) image_mw_coords = ts_coords + dr * cosines[:, None] * imag_mode image_coords = image_mw_coords / np.sqrt(ts_geom.masses_rep) if not ts_geom.is_analytical_2d: align_coords(image_coords) images = [ Geometry(atoms, coords, coord_type="mwcartesian") for coords in image_coords ] instanton = Instanton(images, **kwargs) return instanton
[docs] @classmethod def from_instanton(cls, other, **kwargs): images = other.images instanton = Instanton(images, **kwargs) return instanton
[docs] def as_xyz(self): return "\n".join([geom.as_xyz() for geom in self.images])
@property def coords(self): return np.ravel([image.coords for image in self.images]) @coords.setter def coords(self, coords): coords = coords.reshape(len(self.images), -1) for img_coords, image in zip(coords, self.images): image.coords = img_coords
[docs] def action(self): """Action in au / fs, Hartree per femtosecond.""" all_coords = np.array([image.coords for image in self.images]) diffs = all_coords[self.ks] - all_coords[self.ksm1] dists = np.linalg.norm(diffs, axis=1) S_0 = self.P_bh * (dists**2).sum() energies = [image.energy for image in self.images] S_pot = 1 / self.P_bh * sum(energies) S_E = S_0 / 2 + S_pot results = { "action": S_E, } return results
[docs] def action_gradient(self): """ kin_grad corresponds to the gradient of S_0 in (Eq. 1 in [1], or first term in Eq. 6 in [2].) It boils down to the derivative of a sum of vector norms d sum_k (||y_k - y_(k-1)||_2)² --- d y_k The derivative of a norm of a vector difference is quite simple, but care has to be taken to recognize, that y_k appears two times in the sum. It appears in the first summand for k and in the second summand for k+1. sum_k (||y_k - y_(k-1)||_2)² 1. term 2. term = (||y_k - y_(k-1)||_2)² + (||y_(k+1) - y_k||_2)² + ... and so on The derivative of the first term is 2 * (y_k - y_(k-1)) and the derivative of the second term is -2 * (y_(k+1) - y_k)) which is equal to 2 * (y_k - y_(k+1)) . To summarize: d sum_k(||y_k - y_(k-1)||_2)² --- d y_k = 2 * (2 * y_k - y_(k-1) - y_(k+1)) . """ image_coords = np.array([image.coords for image in self.images]) kin_grad = ( 2 * ( 2 * image_coords # y_k - image_coords[self.ksm1] # y_(k-1) - image_coords[self.ksp1] # y_(k+1) ).flatten() ) kin_grad *= self.P_bh pot_grad = np.array( [ log_progress(image.gradient, "gradient", i) for i, image in enumerate(self.images) ] ).flatten() pot_grad /= self.P_bh gradient = kin_grad / 2 + pot_grad # gradient = pot_grad # gradient = kin_grad # gradient = kin_grad / 2 results = { "gradient": gradient, } results.update(self.action()) return results
[docs] def action_hessian(self): image_hessians = [ log_progress(image.hessian, "hessian", i) for i, image in enumerate(self.images) ] pot_hess = sp.linalg.block_diag(*image_hessians) pot_hess /= self.P_bh coord_num = pot_hess.shape[0] zeroes = np.zeros((coord_num, coord_num)) image_coord_num = self.images[0].coords.size inds = np.arange(coord_num).reshape(-1, image_coord_num) ks = inds[self.ks].flatten() ksm1 = inds[self.ksm1].flatten() ksp1 = inds[self.ksp1].flatten() km = zeroes.copy() km[ks, ksm1] = 1.0 kp = zeroes.copy() kp[ks, ksp1] = 1.0 kin_hess = 4 * np.eye(coord_num) - 2 * km - 2 * kp # y_k # y_k-1 # y_k+1 kin_hess *= self.P_bh hessian = kin_hess / 2 + pot_hess # hessian = pot_hess # hessian = kin_hess / 2 results = { "hessian": hessian, } results.update(self.action()) return results
@property def energy(self): return self.action()["action"] @property def gradient(self): return self.action_gradient()["gradient"] @property def forces(self): return -self.gradient @property def hessian(self): return self.action_hessian()["hessian"] @property def cart_hessian(self): return sp.linalg.block_diag(*[image.cart_hessian for image in self.images]) @property def cart_coords(self): return np.ravel([image.cart_coords for image in self.images]) @property def cart_forces(self): return np.ravel([image.cart_forces for image in self.images]) @property def cart_hessian(self): return sp.linalg.block_diag(*[image.cart_forces for image in self.images])
[docs] def is_analytical_2d(self): return self.images[0].is_analytical_2d
@property def path_length(self): image_coords3d = [image.coords3d for image in self.images] coord_diffs = get_coords_diffs(image_coords3d, align=False, normalize=False) length = coord_diffs.sum() return length
[docs] def get_additional_print(self): length = self.path_length return f"\t\tInstanton length={length:.2f} √a̅m̅u̅·au"