#!/usr/bin/env python
# Copyright 2014-2023 The PySCF Developers. All Rights Reserved.
#
# 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.
#
# Author: Xiaojie Wu <wxj6000@gmail.com>
#
'''
PCM family solvent models
'''
import numpy
import scipy
from pyscf import lib
from pyscf.lib import logger
from pyscf import gto, df
from pyscf.dft import gen_grid
from pyscf.data import radii
from pyscf.solvent import ddcosmo
from pyscf.solvent import _attach_solvent
libdft = lib.load_library('libdft')
[docs]
@lib.with_doc(_attach_solvent._for_scf.__doc__)
def pcm_for_scf(mf, solvent_obj=None, dm=None):
if solvent_obj is None:
solvent_obj = PCM(mf.mol)
return _attach_solvent._for_scf(mf, solvent_obj, dm)
[docs]
@lib.with_doc(_attach_solvent._for_casscf.__doc__)
def pcm_for_casscf(mc, solvent_obj=None, dm=None):
if solvent_obj is None:
if isinstance(getattr(mc._scf, 'with_solvent', None), PCM):
solvent_obj = mc._scf.with_solvent
else:
solvent_obj = PCM(mc.mol)
return _attach_solvent._for_casscf(mc, solvent_obj, dm)
[docs]
@lib.with_doc(_attach_solvent._for_casci.__doc__)
def pcm_for_casci(mc, solvent_obj=None, dm=None):
if solvent_obj is None:
if isinstance(getattr(mc._scf, 'with_solvent', None), PCM):
solvent_obj = mc._scf.with_solvent
else:
solvent_obj = PCM(mc.mol)
return _attach_solvent._for_casci(mc, solvent_obj, dm)
[docs]
@lib.with_doc(_attach_solvent._for_post_scf.__doc__)
def pcm_for_post_scf(method, solvent_obj=None, dm=None):
if solvent_obj is None:
if isinstance(getattr(method._scf, 'with_solvent', None), PCM):
solvent_obj = method._scf.with_solvent
else:
solvent_obj = PCM(method.mol)
return _attach_solvent._for_post_scf(method, solvent_obj, dm)
[docs]
@lib.with_doc(_attach_solvent._for_tdscf.__doc__)
def pcm_for_tdscf(method, solvent_obj=None, dm=None):
scf_solvent = getattr(method._scf, 'with_solvent', None)
assert scf_solvent is None or isinstance(scf_solvent, PCM)
if solvent_obj is None:
solvent_obj = PCM(method.mol)
return _attach_solvent._for_tdscf(method, solvent_obj, dm)
# Inject PCM to other methods
from pyscf import scf
from pyscf import mcscf
from pyscf import mp, ci, cc
from pyscf import tdscf
scf.hf.SCF.PCM = scf.hf.SCF.PCM = pcm_for_scf
mp.mp2.MP2.PCM = mp.mp2.MP2.PCM = pcm_for_post_scf
ci.cisd.CISD.PCM = ci.cisd.CISD.PCM = pcm_for_post_scf
cc.ccsd.CCSD.PCM = cc.ccsd.CCSD.PCM = pcm_for_post_scf
tdscf.rhf.TDBase.PCM = tdscf.rhf.TDBase.PCM = pcm_for_tdscf
mcscf.casci.CASCI.PCM = mcscf.casci.CASCI.PCM = pcm_for_casci
mcscf.mc1step.CASSCF.PCM = mcscf.mc1step.CASSCF.PCM = pcm_for_casscf
# TABLE II, J. Chem. Phys. 122, 194110 (2005)
XI = {
6: 4.84566077868,
14: 4.86458714334,
26: 4.85478226219,
38: 4.90105812685,
50: 4.89250673295,
86: 4.89741372580,
110: 4.90101060987,
146: 4.89825187392,
170: 4.90685517725,
194: 4.90337644248,
302: 4.90498088169,
350: 4.86879474832,
434: 4.90567349080,
590: 4.90624071359,
770: 4.90656435779,
974: 4.90685167998,
1202: 4.90704098216,
1454: 4.90721023869,
1730: 4.90733270691,
2030: 4.90744499142,
2354: 4.90753082825,
2702: 4.90760972766,
3074: 4.90767282394,
3470: 4.90773141371,
3890: 4.90777965981,
4334: 4.90782469526,
4802: 4.90749125553,
5294: 4.90762073452,
5810: 4.90792902522,
}
modified_Bondi = radii.VDW.copy()
modified_Bondi[1] = 1.1/radii.BOHR # modified version
PI = numpy.pi
[docs]
def switch_h(x):
'''
switching function (eq. 3.19)
J. Chem. Phys. 133, 244111 (2010)
notice the typo in the paper
'''
y = x**3 * (10.0 - 15.0*x + 6.0*x**2)
y[x<0] = 0.0
y[x>1] = 1.0
return y
[docs]
def gen_surface(mol, ng=302, rad=modified_Bondi, vdw_scale=1.2):
'''J. Phys. Chem. A 1999, 103, 11060-11079'''
unit_sphere = gen_grid.MakeAngularGrid(ng)
atom_coords = mol.atom_coords(unit='B')
charges = mol.atom_charges()
N_J = ng * numpy.ones(mol.natm)
R_J = numpy.asarray([rad[chg] for chg in charges])
R_sw_J = R_J * (14.0 / N_J)**0.5
alpha_J = 1.0/2.0 + R_J/R_sw_J - ((R_J/R_sw_J)**2 - 1.0/28)**0.5
R_in_J = R_J - alpha_J * R_sw_J
grid_coords = []
weights = []
charge_exp = []
switch_fun = []
R_vdw = []
norm_vec = []
area = []
gslice_by_atom = []
p0 = p1 = 0
for ia in range(mol.natm):
symb = mol.atom_symbol(ia)
chg = gto.charge(symb)
r_vdw = rad[chg]
atom_grid = r_vdw * unit_sphere[:,:3] + atom_coords[ia,:]
riJ = scipy.spatial.distance.cdist(atom_grid[:,:3], atom_coords)
diJ = (riJ - R_in_J) / R_sw_J
diJ[:,ia] = 1.0
diJ[diJ < 1e-8] = 0.0
fiJ = switch_h(diJ)
w = unit_sphere[:,3] * 4.0 * PI
swf = numpy.prod(fiJ, axis=1)
idx = w*swf > 1e-16
p0, p1 = p1, p1+sum(idx)
gslice_by_atom.append([p0,p1])
grid_coords.append(atom_grid[idx,:3])
weights.append(w[idx])
switch_fun.append(swf[idx])
norm_vec.append(unit_sphere[idx,:3])
xi = XI[ng] / (r_vdw * w[idx]**0.5)
charge_exp.append(xi)
R_vdw.append(numpy.ones(sum(idx)) * r_vdw)
area.append(w[idx]*r_vdw**2*swf[idx])
grid_coords = numpy.vstack(grid_coords)
norm_vec = numpy.vstack(norm_vec)
weights = numpy.concatenate(weights)
charge_exp = numpy.concatenate(charge_exp)
switch_fun = numpy.concatenate(switch_fun)
area = numpy.concatenate(area)
R_vdw = numpy.concatenate(R_vdw)
surface = {
'ng': ng,
'gslice_by_atom': gslice_by_atom,
'grid_coords': grid_coords,
'weights': weights,
'charge_exp': charge_exp,
'switch_fun': switch_fun,
'R_vdw': R_vdw,
'norm_vec': norm_vec,
'area': area,
'R_in_J': R_in_J,
'R_sw_J': R_sw_J,
'atom_coords': atom_coords
}
return surface
[docs]
def get_F_A(surface):
'''
generate F and A matrix in J. Chem. Phys. 133, 244111 (2010)
'''
R_vdw = surface['R_vdw']
switch_fun = surface['switch_fun']
weights = surface['weights']
A = weights*R_vdw**2*switch_fun
return switch_fun, A
[docs]
def get_D_S(surface, with_S=True, with_D=False):
'''
generate D and S matrix in J. Chem. Phys. 133, 244111 (2010)
The diagonal entries of S is not filled
'''
charge_exp = surface['charge_exp']
grid_coords = surface['grid_coords']
switch_fun = surface['switch_fun']
norm_vec = surface['norm_vec']
R_vdw = surface['R_vdw']
xi_i, xi_j = numpy.meshgrid(charge_exp, charge_exp, indexing='ij')
xi_ij = xi_i * xi_j / (xi_i**2 + xi_j**2)**0.5
rij = scipy.spatial.distance.cdist(grid_coords, grid_coords)
xi_r_ij = xi_ij * rij
numpy.fill_diagonal(rij, 1)
S = scipy.special.erf(xi_r_ij) / rij
numpy.fill_diagonal(S, charge_exp * (2.0 / PI)**0.5 / switch_fun)
D = None
if with_D:
drij = numpy.expand_dims(grid_coords, axis=1) - grid_coords
nrij = numpy.sum(drij * norm_vec, axis=-1)
D = S*nrij/rij**2 -2.0*xi_r_ij/PI**0.5*numpy.exp(-xi_r_ij**2)*nrij/rij**3
numpy.fill_diagonal(D, -charge_exp * (2.0 / PI)**0.5 / (2.0 * R_vdw))
return D, S
[docs]
class PCM(lib.StreamObject):
_keys = {
'method', 'vdw_scale', 'surface', 'r_probe', 'intopt',
'mol', 'radii_table', 'atom_radii', 'lebedev_order', 'lmax', 'eta',
'eps', 'max_cycle', 'conv_tol', 'state_id', 'frozen',
'equilibrium_solvation', 'e', 'v', 'v_grids_n',
}
kernel = ddcosmo.DDCOSMO.kernel
def __init__(self, mol):
self.mol = mol
self.stdout = mol.stdout
self.verbose = mol.verbose
self.max_memory = mol.max_memory
self.method = 'C-PCM'
self.vdw_scale = 1.2 # default value in qchem
self.surface = {}
self.r_probe = 0.0
self.radii_table = None
self.atom_radii = None
self.lebedev_order = 29
self._intermediates = {}
self.eps = 78.3553
self.max_cycle = 20
self.conv_tol = 1e-7
self.state_id = 0
self.frozen = False
self.equilibrium_solvation = False
self.e = None
self.v = None
self._dm = None
[docs]
def dump_flags(self, verbose=None):
logger.info(self, '******** %s (In testing) ********', self.__class__)
logger.warn(self, 'PCM is an experimental feature. It is '
'still in testing.\nFeatures and APIs may be changed '
'in the future.')
logger.info(self, 'lebedev_order = %s (%d grids per sphere)',
self.lebedev_order, gen_grid.LEBEDEV_ORDER[self.lebedev_order])
logger.info(self, 'eps = %s' , self.eps)
logger.info(self, 'frozen = %s' , self.frozen)
logger.info(self, 'equilibrium_solvation = %s', self.equilibrium_solvation)
logger.debug2(self, 'radii_table %s', self.radii_table)
if self.atom_radii:
logger.info(self, 'User specified atomic radii %s', str(self.atom_radii))
return self
[docs]
def to_gpu(self):
from pyscf.lib import to_gpu
obj = to_gpu(self)
return obj.reset()
[docs]
def reset(self, mol=None):
if mol is not None:
self.mol = mol
self._intermediates = None
self.surface = None
self.intopt = None
return self
[docs]
def build(self, ng=None):
if self.radii_table is None:
vdw_scale = self.vdw_scale
self.radii_table = vdw_scale * modified_Bondi + self.r_probe
mol = self.mol
if ng is None:
ng = gen_grid.LEBEDEV_ORDER[self.lebedev_order]
self.surface = gen_surface(mol, rad=self.radii_table, ng=ng)
self._intermediates = {}
F, A = get_F_A(self.surface)
D, S = get_D_S(self.surface, with_S=True, with_D=True)
epsilon = self.eps
if self.method.upper() in ['C-PCM', 'CPCM']:
f_epsilon = (epsilon-1.)/epsilon
K = S
R = -f_epsilon * numpy.eye(K.shape[0])
elif self.method.upper() == 'COSMO':
f_epsilon = (epsilon - 1.0)/(epsilon + 1.0/2.0)
K = S
R = -f_epsilon * numpy.eye(K.shape[0])
elif self.method.upper() in ['IEF-PCM', 'IEFPCM']:
f_epsilon = (epsilon - 1.0)/(epsilon + 1.0)
DA = D*A
DAS = numpy.dot(DA, S)
K = S - f_epsilon/(2.0*PI) * DAS
R = -f_epsilon * (numpy.eye(K.shape[0]) - 1.0/(2.0*PI)*DA)
elif self.method.upper() == 'SS(V)PE':
f_epsilon = (epsilon - 1.0)/(epsilon + 1.0)
DA = D*A
DAS = numpy.dot(DA, S)
K = S - f_epsilon/(4.0*PI) * (DAS + DAS.T)
R = -f_epsilon * (numpy.eye(K.shape[0]) - 1.0/(2.0*PI)*DA)
else:
raise RuntimeError(f"Unknown implicit solvent model: {self.method}")
intermediates = {
'S': S,
'D': D,
'A': A,
'K': K,
'R': R,
'f_epsilon': f_epsilon
}
self._intermediates.update(intermediates)
charge_exp = self.surface['charge_exp']
grid_coords = self.surface['grid_coords']
atom_coords = mol.atom_coords(unit='B')
atom_charges = mol.atom_charges()
int2c2e = mol._add_suffix('int2c2e')
fakemol = gto.fakemol_for_charges(grid_coords, expnt=charge_exp**2)
fakemol_nuc = gto.fakemol_for_charges(atom_coords)
v_ng = gto.mole.intor_cross(int2c2e, fakemol_nuc, fakemol)
self.v_grids_n = numpy.dot(atom_charges, v_ng)
def _get_vind(self, dms):
if not self._intermediates:
self.build()
nao = dms.shape[-1]
dms = dms.reshape(-1,nao,nao)
if dms.shape[0] == 2:
dms = (dms[0] + dms[1]).reshape(-1,nao,nao)
K = self._intermediates['K']
R = self._intermediates['R']
v_grids_e = self._get_v(dms)
v_grids = self.v_grids_n - v_grids_e
b = numpy.dot(R, v_grids.T)
q = numpy.linalg.solve(K, b).T
vK_1 = numpy.linalg.solve(K.T, v_grids.T)
qt = numpy.dot(R.T, vK_1).T
q_sym = (q + qt)/2.0
vmat = self._get_vmat(q_sym)
epcm = 0.5 * numpy.dot(q_sym[0], v_grids[0])
self._intermediates['q'] = q[0]
self._intermediates['q_sym'] = q_sym[0]
self._intermediates['v_grids'] = v_grids[0]
self._intermediates['dm'] = dms
return epcm, vmat[0]
def _get_v(self, dms):
'''
return electrostatic potential on surface
'''
mol = self.mol
nao = dms.shape[-1]
grid_coords = self.surface['grid_coords']
exponents = self.surface['charge_exp']
ngrids = grid_coords.shape[0]
nset = dms.shape[0]
v_grids_e = numpy.empty([nset, ngrids])
max_memory = self.max_memory - lib.current_memory()[0]
blksize = int(max(max_memory*.9e6/8/nao**2, 400))
int3c2e = mol._add_suffix('int3c2e')
cintopt = gto.moleintor.make_cintopt(mol._atm, mol._bas, mol._env, int3c2e)
for p0, p1 in lib.prange(0, ngrids, blksize):
fakemol = gto.fakemol_for_charges(grid_coords[p0:p1], expnt=exponents[p0:p1]**2)
v_nj = df.incore.aux_e2(mol, fakemol, intor=int3c2e, aosym='s1', cintopt=cintopt)
for i in range(nset):
v_grids_e[i,p0:p1] = numpy.einsum('ijL,ij->L',v_nj, dms[i])
return v_grids_e
def _get_vmat(self, q):
mol = self.mol
nao = mol.nao
grid_coords = self.surface['grid_coords']
exponents = self.surface['charge_exp']
ngrids = grid_coords.shape[0]
q = q.reshape([-1,ngrids])
nset = q.shape[0]
vmat = numpy.zeros([nset,nao,nao])
max_memory = self.max_memory - lib.current_memory()[0]
blksize = int(max(max_memory*.9e6/8/nao**2, 400))
int3c2e = mol._add_suffix('int3c2e')
cintopt = gto.moleintor.make_cintopt(mol._atm, mol._bas, mol._env, int3c2e)
for p0, p1 in lib.prange(0, ngrids, blksize):
fakemol = gto.fakemol_for_charges(grid_coords[p0:p1], expnt=exponents[p0:p1]**2)
v_nj = df.incore.aux_e2(mol, fakemol, intor=int3c2e, aosym='s1', cintopt=cintopt)
for i in range(nset):
vmat[i] += -numpy.einsum('ijL,L->ij', v_nj, q[i,p0:p1])
return vmat
[docs]
def nuc_grad_method(self, grad_method):
from pyscf.solvent.grad import pcm as pcm_grad
if self.frozen:
raise RuntimeError('Frozen solvent model is not supported')
from pyscf import scf
from pyscf.solvent import _ddcosmo_tdscf_grad
if isinstance(grad_method.base, tdscf.rhf.TDBase):
return _ddcosmo_tdscf_grad.make_grad_object(grad_method)
else:
return pcm_grad.make_grad_object(grad_method)
[docs]
def Hessian(self, hess_method):
from pyscf.solvent.hessian import pcm as pcm_hess
if self.frozen:
raise RuntimeError('Frozen solvent model is not supported')
from pyscf import scf
if isinstance(hess_method.base, (scf.hf.RHF, scf.uhf.UHF)):
return pcm_hess.make_hess_object(hess_method)
else:
raise RuntimeError('Only SCF gradient is supported')
def _B_dot_x(self, dms):
if not self._intermediates:
self.build()
out_shape = dms.shape
nao = dms.shape[-1]
dms = dms.reshape(-1,nao,nao)
K = self._intermediates['K']
R = self._intermediates['R']
v_grids = -self._get_v(dms)
b = numpy.dot(R, v_grids.T)
q = numpy.linalg.solve(K, b).T
vK_1 = numpy.linalg.solve(K.T, v_grids.T)
qt = numpy.dot(R.T, vK_1).T
q_sym = (q + qt)/2.0
vmat = self._get_vmat(q_sym)
return vmat.reshape(out_shape)