Source code for pyscf.pbc.grad.rks_stress

#!/usr/bin/env python
# Copyright 2025 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.

r'''
The energy derivatives for the strain tensor e_ij is

                1  d E
    sigma_ij = --- ------
                V  d e_ij

The strain tesnor e_ij describes the transformation for real space coordinates
in the crystal

    \sum_j [\deta_ij + e_ij] R_j  [for j = x, y, z]

Due to numerical errors, the strain tensor may slightly break the symmetry
within the stress tensor. The 6 independent components of the stress tensor

    [e1   e6/2 e5/2]
    [e6/2 e2   e4/2]
    [e5/2 e4/2 e3  ]

is constructed by symmetrizing the strain tensor as follows:

    e1 = e_11
    e2 = e_22
    e3 = e_33
    e6 = e_12 + e_21
    e5 = e_13 + e_31
    e4 = e_32 + e_23

See K. Doll, Mol Phys (2010), 108, 223
'''

import numpy as np
from pyscf import lib
from pyscf import gto
from pyscf.lib import logger
from pyscf.pbc.tools import pbc as pbctools
from pyscf.pbc.lib.kpts_helper import is_zero
from pyscf.pbc.dft.gen_grid import UniformGrids
from pyscf.pbc.gto import pseudo
from pyscf.pbc.df import FFTDF, ft_ao
from pyscf.pbc.dft.numint import NumInt, _contract_rho
from pyscf.pbc.grad import rks as rks_grad

[docs] def strain_tensor_dispalcement(x, y, disp): E_strain = np.eye(3) E_strain[x,y] += disp return E_strain
def _finite_diff_cells(cell, x, y, disp=1e-4, precision=None): if precision is not None: cell = cell.copy() cell.precision = precision a = cell.lattice_vectors() r = cell.atom_coords() if not gto.mole.is_au(cell.unit): a *= lib.param.BOHR r *= lib.param.BOHR e_strain = strain_tensor_dispalcement(x, y, disp) cell1 = cell.set_geom_(r.dot(e_strain.T), inplace=False) cell1.a = a.dot(e_strain.T) e_strain = strain_tensor_dispalcement(x, y, -disp) cell2 = cell.set_geom_(r.dot(e_strain.T), inplace=False) cell2.a = a.dot(e_strain.T) if cell.space_group_symmetry: cell1.build(False, False) cell2.build(False, False) return cell1, cell2
[docs] def get_ovlp(cell): '''Strain derivatives for overlap matrix ''' disp = 1e-5 s = [] for x in range(3): for y in range(3): cell1, cell2 = _finite_diff_cells(cell, x, y, disp) s1 = cell1.pbc_intor('int1e_ovlp') s2 = cell2.pbc_intor('int1e_ovlp') s.append((s1 - s2) / (2*disp)) return s
[docs] def get_kin(cell): '''Strain derivatives for kinetic matrix ''' disp = 1e-5 t = [] for x in range(3): for y in range(3): cell1, cell2 = _finite_diff_cells(cell, x, y, disp) t1 = cell1.pbc_intor('int1e_kin') t2 = cell2.pbc_intor('int1e_kin') t.append((t1 - t2) / (2*disp)) return t
def _get_coulG_strain_derivatives(cell, Gv): '''derivatives of 4pi/G^2''' G2 = np.einsum('gx,gx->g', Gv, Gv) G2[0] = np.inf coulG_0 = 4 * np.pi / G2 coulG_1 = np.einsum('gx,gy->xyg', Gv, Gv) coulG_1 *= coulG_0 * 2/G2 return coulG_0, coulG_1 def _get_weight_strain_derivatives(cell, grids): ngrids = grids.size weight_0 = cell.vol / ngrids weight_1 = np.eye(3) * weight_0 return weight_0, weight_1 def _eval_ao_strain_derivatives(cell, coords, kpts=None, deriv=0, out=None): ''' Returns: ao_kpts: (nkpts, 3, 3, comp, ngrids, nao) ndarray AO values at each k-point ''' comp = (deriv+1)*(deriv+2)*(deriv+3)//6 comp_3x3 = comp * 9 if cell.cart: feval = 'GTOval_cart_deriv%d_strain_tensor' % deriv else: feval = 'GTOval_sph_deriv%d_strain_tensor' % deriv out = cell.pbc_eval_gto(feval, coords, comp_3x3, kpts, out=out) ngrids = len(coords) if isinstance(out, np.ndarray): out = [out.reshape(3,3,comp,ngrids,-1)] else: out = [x.reshape(3,3,comp,ngrids,-1) for x in out] return out
[docs] def get_vxc(ks_grad, cell, dm, with_j=False, with_nuc=False): '''Strain derivatives for Coulomb and XC at gamma point Kwargs: with_j : Whether to include the electron-electron Coulomb interactions with_nuc : Whether to include the electron-nuclear Coulomb interactions ''' mf = ks_grad.base if dm is None: dm = mf.make_rdm1() assert cell.low_dim_ft_type != 'inf_vacuum' assert cell.dimension != 1 ni = mf._numint assert isinstance(ni, NumInt) if ks_grad.grids is not None: grids = ks_grad.grids else: grids = mf.grids assert isinstance(grids, UniformGrids) xc_code = mf.xc xctype = ni._xc_type(xc_code) if xctype == 'LDA': deriv = 0 nvar = 1 elif xctype == 'GGA': deriv = 1 nvar = 4 elif xctype == 'MGGA': deriv = 1 nvar = 5 else: raise NotImplementedError assert dm.ndim == 2 nao = dm.shape[-1] coords = grids.coords ngrids = len(coords) mesh = grids.mesh weight_0, weight_1 = _get_weight_strain_derivatives(cell, grids) out = np.zeros((3,3)) rho0 = np.empty((nvar, ngrids)) rho1 = np.empty((3,3, nvar, ngrids)) def partial_dot(bra, ket): '''conj(ig),ig->g''' # Adapt to the _contract_rho function return _contract_rho(bra.T, ket.T) XY, YY, ZY, XZ, YZ, ZZ = 5, 7, 8, 6, 8, 9 p1 = 0 for ao, _, mask, weight, coords \ in ni.block_loop(cell, grids, nao, deriv+1): p0, p1 = p1, p1 + weight.size ao_strain = _eval_ao_strain_derivatives(cell, coords, deriv=deriv)[0] ao = ao.transpose(0,2,1) ao_strain = ao_strain.transpose(0,1,2,4,3) coordsT = np.asarray(coords.T, order='C') if xctype == 'LDA': ao1 = ao_strain[:,:,0] # Adding the response of the grids ao1 += np.einsum('xig,yg->xyig', ao[1:4], coordsT) c0 = dm.T.dot(ao[0]) rho0[0,p0:p1] = partial_dot(ao[0], c0).real rho1[:,:,0,p0:p1] = np.einsum('xyig,ig->xyg', ao1, c0.conj()).real elif xctype == 'GGA': ao_strain[:,:,0] += np.einsum('xig,yg->xyig', ao[1:4], coordsT) ao_strain[:,:,1] += np.einsum('xig,yg->xyig', ao[4:7], coordsT) ao_strain[0,:,2] += np.einsum('ig,yg->yig', ao[XY], coordsT) ao_strain[1,:,2] += np.einsum('ig,yg->yig', ao[YY], coordsT) ao_strain[2,:,2] += np.einsum('ig,yg->yig', ao[ZY], coordsT) ao_strain[0,:,3] += np.einsum('ig,yg->yig', ao[XZ], coordsT) ao_strain[1,:,3] += np.einsum('ig,yg->yig', ao[YZ], coordsT) ao_strain[2,:,3] += np.einsum('ig,yg->yig', ao[ZZ], coordsT) c0 = lib.einsum('xig,ij->xjg', ao[:4], dm) for i in range(4): rho0[i,p0:p1] = partial_dot(ao[0], c0[i]).real rho1[:,:, : ,p0:p1] = np.einsum('xynig,ig->xyng', ao_strain, c0[0].conj()).real rho1[:,:,1:4,p0:p1] += np.einsum('xyig,nig->xyng', ao_strain[:,:,0], c0[1:4].conj()).real else: # MGGA ao_strain[:,:,0] += np.einsum('xig,yg->xyig', ao[1:4], coordsT) ao_strain[:,:,1] += np.einsum('xig,yg->xyig', ao[4:7], coordsT) ao_strain[0,:,2] += np.einsum('ig,yg->yig', ao[XY], coordsT) ao_strain[1,:,2] += np.einsum('ig,yg->yig', ao[YY], coordsT) ao_strain[2,:,2] += np.einsum('ig,yg->yig', ao[ZY], coordsT) ao_strain[0,:,3] += np.einsum('ig,yg->yig', ao[XZ], coordsT) ao_strain[1,:,3] += np.einsum('ig,yg->yig', ao[YZ], coordsT) ao_strain[2,:,3] += np.einsum('ig,yg->yig', ao[ZZ], coordsT) c0 = lib.einsum('xig,ij->xjg', ao[:4], dm) for i in range(4): rho0[i,p0:p1] = partial_dot(ao[0], c0[i]).real rho0[4,p0:p1] = partial_dot(ao[1], c0[1]).real rho0[4,p0:p1] += partial_dot(ao[2], c0[2]).real rho0[4,p0:p1] += partial_dot(ao[3], c0[3]).real rho1[:,:, :4,p0:p1] = np.einsum('xynig,ig->xyng', ao_strain, c0[0].conj()).real rho1[:,:,1:4,p0:p1] += np.einsum('xyig,nig->xyng', ao_strain[:,:,0], c0[1:4].conj()).real rho1[:,:,4,p0:p1] = np.einsum('xynig,nig->xyg', ao_strain[:,:,1:4], c0[1:4].conj()).real if xctype == 'LDA': pass elif xctype == 'GGA': rho0[1:4] *= 2 # dm should be hermitian else: # MGGA rho0[1:4] *= 2 # dm should be hermitian rho0[4] *= .5 # factor 1/2 for tau rho1[:,:,4] *= .5 # *2 for rho1 because the derivatives were applied to the bra only rho1 *= 2. exc, vxc = ni.eval_xc_eff(xc_code, rho0, 1, xctype=xctype, spin=0)[:2] out += np.einsum('xyng,ng->xy', rho1, vxc).real * weight_0 out += np.einsum('g,g->', rho0[0], exc).real * weight_1 Gv = cell.get_Gv(mesh) coulG_0, coulG_1 = _get_coulG_strain_derivatives(cell, Gv) rhoG = pbctools.fft(rho0[0], mesh) if with_j: vR = pbctools.ifft(rhoG * coulG_0, mesh) EJ = np.einsum('xyg,g->xy', rho1[:,:,0], vR).real * weight_0 * 2 EJ += np.einsum('g,g->', rho0[0], vR).real * weight_1 EJ += np.einsum('g,xyg,g->xy', rhoG.conj(), coulG_1, rhoG).real * (weight_0/ngrids) out += .5 * EJ if with_nuc: if cell._pseudo: vpplocG_0, vpplocG_1 = _get_vpplocG_strain_derivatives(cell, mesh) vpplocR = pbctools.ifft(vpplocG_0, mesh).real Ene = np.einsum('xyg,g->xy', rho1[:,:,0], vpplocR).real Ene += np.einsum('g,xyg->xy', rhoG.conj(), vpplocG_1).real * (1./ngrids) Ene += _get_pp_nonloc_strain_derivatives(cell, mesh, dm) else: charge = -cell.atom_charges() # SI corresponds to Fourier components of the fractional atomic # positions within the cell. It does not respond to the strain # transformation SI = cell.get_SI(mesh=mesh) ZG = np.dot(charge, SI) vR = pbctools.ifft(ZG * coulG_0, mesh).real Ene = np.einsum('xyg,g->xy', rho1[:,:,0], vR).real Ene += np.einsum('g,xyg,g->xy', rhoG.conj(), coulG_1, ZG).real * (1./ngrids) out += Ene return out
def _get_vpplocG_strain_derivatives(cell, mesh): disp = 1e-5 ngrids = np.prod(mesh) v1 = np.empty((3,3, ngrids), dtype=np.complex128) SI = cell.get_SI(mesh=mesh) for x in range(3): for y in range(3): cell1, cell2 = _finite_diff_cells(cell, x, y, disp) vpplocG1 = pseudo.get_vlocG(cell1, cell1.get_Gv(mesh)) vpplocG2 = pseudo.get_vlocG(cell2, cell2.get_Gv(mesh)) vpplocG1 = -np.einsum('ij,ij->j', SI, vpplocG1) vpplocG2 = -np.einsum('ij,ij->j', SI, vpplocG2) v1[x,y] = (vpplocG1 - vpplocG2) / (2*disp) vpplocG = pseudo.get_vlocG(cell, cell.get_Gv(mesh)) v0 = -np.einsum('ij,ij->j', SI, vpplocG) return v0, v1 def _get_pp_nonloc_strain_derivatives(cell, mesh, dm_kpts, kpts=None): if kpts is None: assert dm_kpts.ndim == 2 dm_kpts = dm_kpts[None,:,:] kpts = np.zeros((1, 3)) fakemol = gto.Mole() fakemol._atm = np.zeros((1,gto.ATM_SLOTS), dtype=np.int32) fakemol._bas = np.zeros((1,gto.BAS_SLOTS), dtype=np.int32) ptr = gto.PTR_ENV_START fakemol._env = np.zeros(ptr+10) fakemol._bas[0,gto.NPRIM_OF ] = 1 fakemol._bas[0,gto.NCTR_OF ] = 1 fakemol._bas[0,gto.PTR_EXP ] = ptr+3 fakemol._bas[0,gto.PTR_COEFF] = ptr+4 ngrids = np.prod(mesh) buf = np.empty((48,ngrids), dtype=np.complex128) scaled_kpts = kpts.dot(cell.lattice_vectors().T) nkpts = len(kpts) def eval_pp_nonloc(cell): vol = cell.vol b = cell.reciprocal_vectors(norm_to=1) Gv = cell.get_Gv(mesh) SI = cell.get_SI(mesh=mesh) # buf for SPG_lmi upto l=0..3 and nl=3 vppnl = 0 for k, dm in enumerate(dm_kpts): kpt = scaled_kpts[k].dot(b) Gk = Gv + kpt G_rad = lib.norm(Gk, axis=1) aokG = ft_ao.ft_ao(cell, Gv, kpt=kpt) * (1/vol)**.5 for ia in range(cell.natm): symb = cell.atom_symbol(ia) if symb not in cell._pseudo: continue pp = cell._pseudo[symb] p1 = 0 for l, proj in enumerate(pp[5:]): rl, nl, hl = proj if nl > 0: fakemol._bas[0,gto.ANG_OF] = l fakemol._env[ptr+3] = .5*rl**2 fakemol._env[ptr+4] = rl**(l+1.5)*np.pi**1.25 pYlm_part = fakemol.eval_gto('GTOval', Gk) p0, p1 = p1, p1+nl*(l*2+1) # pYlm is real, SI[ia] is complex pYlm = np.ndarray((nl,l*2+1,ngrids), dtype=np.complex128, buffer=buf[p0:p1]) for k in range(nl): qkl = pseudo.pp._qli(G_rad*rl, l, k) pYlm[k] = pYlm_part.T * qkl if p1 > 0: SPG_lmi = buf[:p1] SPG_lmi *= SI[ia].conj() SPG_lm_aoGs = SPG_lmi.dot(aokG) rho = SPG_lm_aoGs.dot(dm).dot(SPG_lm_aoGs.conj().T).real p1 = 0 for l, proj in enumerate(pp[5:]): rl, nl, hl = proj if nl > 0: nf = l * 2 + 1 p0, p1 = p1, p1+nl*nf hl = np.asarray(hl) rho_sub = rho[p0:p1,p0:p1].reshape(nl, nf, nl, nf) vppnl += np.einsum('ij,jmim->', hl, rho_sub) return vppnl / (nkpts*vol) disp = max(1e-5, (cell.precision*.1)**.5) out = np.empty((3, 3)) for i in range(3): for j in range(3): cell1, cell2 = _finite_diff_cells(cell, i, j, disp) e1 = eval_pp_nonloc(cell1) e2 = eval_pp_nonloc(cell2) out[i,j] = (e1 - e2) / (2*disp) return out
[docs] def ewald(cell): disp = max(1e-5, (cell.precision*.1)**.5) out = np.empty((3, 3)) for i in range(3): for j in range(i+1): cell1, cell2 = _finite_diff_cells(cell, i, j, disp) e1 = cell1.ewald() e2 = cell2.ewald() out[j,i] = out[i,j] = (e1 - e2) / (2*disp) return out
[docs] def kernel(mf_grad): '''Compute the energy derivatives for strain tensor (e_ij) 1 d E sigma_ij = --- ------ V d e_ij sigma is a asymmetric 3x3 matrix. The symmetric stress tensor in the 6 Voigt notation can be transformed from the asymmetric stress tensor sigma1 = sigma_11 sigma2 = sigma_22 sigma3 = sigma_33 sigma6 = (sigma_12 + sigma_21)/2 sigma5 = (sigma_13 + sigma_31)/2 sigma4 = (sigma_23 + sigma_32)/2 See K. Doll, Mol Phys (2010), 108, 223 ''' assert isinstance(mf_grad, rks_grad.Gradients) mf = mf_grad.base assert is_zero(mf.kpt) with_df = mf.with_df assert isinstance(with_df, FFTDF) ni = mf._numint if ni.libxc.is_hybrid_xc(mf.xc): raise NotImplementedError('Stress tensor for hybrid DFT') if hasattr(mf, 'U_idx'): raise NotImplementedError('Stress tensor for DFT+U') log = logger.new_logger(mf_grad) t0 = (logger.process_clock(), logger.perf_counter()) log.debug('Computing stress tensor') cell = mf.cell dm0 = mf.make_rdm1() dme0 = mf_grad.make_rdm1e() sigma = ewald(cell) disp = 1e-5 for x in range(3): for y in range(3): cell1, cell2 = _finite_diff_cells(cell, x, y, disp) t1 = cell1.pbc_intor('int1e_kin') t2 = cell2.pbc_intor('int1e_kin') t1 = np.einsum('ij,ji->', t1, dm0) t2 = np.einsum('ij,ji->', t2, dm0) sigma[x,y] += (t1 - t2) / (2*disp) s1 = cell1.pbc_intor('int1e_ovlp') s2 = cell2.pbc_intor('int1e_ovlp') s1 = np.einsum('ij,ji->', s1, dme0) s2 = np.einsum('ij,ji->', s2, dme0) sigma[x,y] -= (s1 - s2) / (2*disp) t0 = log.timer_debug1('hcore derivatives', *t0) sigma += get_vxc(mf_grad, cell, dm0, with_j=True, with_nuc=True) t0 = log.timer_debug1('Vxc and Coulomb derivatives', *t0) sigma /= cell.vol if log.verbose >= logger.DEBUG: log.debug('Asymmetric strain tensor') log.debug('%s', sigma) return sigma
# TODO: DFT+U