Coverage for /builds/debichem-team/python-ase/ase/calculators/harmonic.py: 97.56%

164 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2025-03-06 04:00 +0000

1import numpy as np 

2from numpy.linalg import eigh, norm, pinv 

3from scipy.linalg import lstsq # performs better than numpy.linalg.lstsq 

4 

5from ase import units 

6from ase.calculators.calculator import ( 

7 BaseCalculator, 

8 CalculationFailed, 

9 Calculator, 

10 CalculatorSetupError, 

11 all_changes, 

12) 

13 

14 

15class HarmonicCalculator(BaseCalculator): 

16 """Class for calculations with a Hessian-based harmonic force field. 

17 

18 See :class:`HarmonicForceField` and the literature. [1]_ 

19 """ 

20 

21 implemented_properties = ['energy', 'forces'] 

22 

23 def __init__(self, harmonicforcefield): 

24 """ 

25 Parameters 

26 ---------- 

27 harmonicforcefield: :class:`HarmonicForceField` 

28 Class for calculations with a Hessian-based harmonic force field. 

29 """ 

30 super().__init__() # parameters have been passed to the force field 

31 self.harmonicforcefield = harmonicforcefield 

32 

33 def calculate(self, atoms, properties, system_changes): 

34 self.atoms = atoms.copy() # for caching of results 

35 energy, forces_x = self.harmonicforcefield.get_energy_forces(atoms) 

36 self.results['energy'] = energy 

37 self.results['forces'] = forces_x 

38 

39 

40class HarmonicForceField: 

41 def __init__(self, ref_atoms, hessian_x, ref_energy=0.0, get_q_from_x=None, 

42 get_jacobian=None, cartesian=True, variable_orientation=False, 

43 hessian_limit=0.0, constrained_q=None, rcond=1e-7, 

44 zero_thresh=0.0): 

45 """Class that represents a Hessian-based harmonic force field. 

46 

47 Energy and forces of this force field are based on the 

48 Cartesian Hessian for a local reference configuration, i.e. if 

49 desired, on the Hessian matrix transformed to a user-defined 

50 coordinate system. The required Hessian has to be passed as 

51 an argument, e.g. predetermined numerically via central finite 

52 differences in Cartesian coordinates. Note that a potential 

53 being harmonic in Cartesian coordinates **x** is not 

54 necessarily equivalently harmonic in another coordinate system 

55 **q**, e.g. when the transformation between the coordinate 

56 systems is non-linear. By default, the force field is 

57 evaluated in Cartesian coordinates in which energy and forces 

58 are not rotationally and translationally invariant. Systems 

59 with variable orientation, require rotationally and 

60 translationally invariant calculations for which a set of 

61 appropriate coordinates has to be defined. This can be a set 

62 of (redundant) internal coordinates (bonds, angles, dihedrals, 

63 coordination numbers, ...) or any other user-defined 

64 coordinate system. 

65 

66 Together with the :class:`HarmonicCalculator` this 

67 :class:`HarmonicForceField` can be used to compute 

68 Anharmonic Corrections to the Harmonic Approximation. [1]_ 

69 

70 Parameters 

71 ---------- 

72 ref_atoms: :class:`~ase.Atoms` object 

73 Reference structure for which energy (``ref_energy``) and Hessian 

74 matrix in Cartesian coordinates (``hessian_x``) are provided. 

75 

76 hessian_x: numpy array 

77 Cartesian Hessian matrix for the reference structure ``ref_atoms``. 

78 If a user-defined coordinate system is provided via 

79 ``get_q_from_x`` and ``get_jacobian``, the Cartesian Hessian matrix 

80 is transformed to the user-defined coordinate system and back to 

81 Cartesian coordinates, thereby eliminating rotational and 

82 translational traits from the Hessian. The Hessian matrix 

83 obtained after this double-transformation is then used as 

84 the reference Hessian matrix to evaluate energy and forces for 

85 ``cartesian = True``. For ``cartesian = False`` the reference 

86 Hessian matrix transformed to the user-defined coordinates is used 

87 to compute energy and forces. 

88 

89 ref_energy: float 

90 Energy of the reference structure ``ref_atoms``, typically in `eV`. 

91 

92 get_q_from_x: python function, default: None (Cartesian coordinates) 

93 Function that returns a vector of user-defined coordinates **q** for 

94 a given :class:`~ase.Atoms` object 'atoms'. The signature should be: 

95 :obj:`get_q_from_x(atoms)`. 

96 

97 get_jacobian: python function, default: None (Cartesian coordinates) 

98 Function that returns the geometric Jacobian matrix of the 

99 user-defined coordinates **q** w.r.t. Cartesian coordinates **x** 

100 defined as `dq/dx` (Wilson B-matrix) for a given :class:`~ase.Atoms` 

101 object 'atoms'. The signature should be: :obj:`get_jacobian(atoms)`. 

102 

103 cartesian: bool 

104 Set to True to evaluate energy and forces based on the reference 

105 Hessian (system harmonic in Cartesian coordinates). 

106 Set to False to evaluate energy and forces based on the reference 

107 Hessian transformed to user-defined coordinates (system harmonic in 

108 user-defined coordinates). 

109 

110 hessian_limit: float 

111 Reconstruct the reference Hessian matrix with a lower limit for the 

112 eigenvalues, typically in `eV/A^2`. Eigenvalues in the interval 

113 [``zero_thresh``, ``hessian_limit``] are set to ``hessian_limit`` 

114 while the eigenvectors are left untouched. 

115 

116 variable_orientation: bool 

117 Set to True if the investigated :class:`~ase.Atoms` has got 

118 rotational degrees of freedom such that the orientation with respect 

119 to ``ref_atoms`` might be different (typically for molecules). 

120 Set to False to speed up the calculation when ``cartesian = True``. 

121 

122 constrained_q: list 

123 A list of indices 'i' of constrained coordinates `q_i` to be 

124 projected out from the Hessian matrix 

125 (e.g. remove forces along imaginary mode of a transition state). 

126 

127 rcond: float 

128 Cutoff for singular value decomposition in the computation of the 

129 Moore-Penrose pseudo-inverse during transformation of the Hessian 

130 matrix. Equivalent to the rcond parameter in scipy.linalg.lstsq. 

131 

132 zero_thresh: float 

133 Reconstruct the reference Hessian matrix with absolute eigenvalues 

134 below this threshold set to zero. 

135 

136 """ 

137 self.check_input([get_q_from_x, get_jacobian], 

138 variable_orientation, cartesian) 

139 

140 self.parameters = {'ref_atoms': ref_atoms, 

141 'ref_energy': ref_energy, 

142 'hessian_x': hessian_x, 

143 'hessian_limit': hessian_limit, 

144 'get_q_from_x': get_q_from_x, 

145 'get_jacobian': get_jacobian, 

146 'cartesian': cartesian, 

147 'variable_orientation': variable_orientation, 

148 'constrained_q': constrained_q, 

149 'rcond': rcond, 

150 'zero_thresh': zero_thresh} 

151 

152 # set up user-defined coordinate system or Cartesian coordinates 

153 self.get_q_from_x = (self.parameters['get_q_from_x'] or 

154 (lambda atoms: atoms.get_positions())) 

155 self.get_jacobian = (self.parameters['get_jacobian'] or 

156 (lambda atoms: np.diagflat( 

157 np.ones(3 * len(atoms))))) 

158 

159 # reference Cartesian coords. x0; reference user-defined coords. q0 

160 self.x0 = self.parameters['ref_atoms'].get_positions().ravel() 

161 self.q0 = self.get_q_from_x(self.parameters['ref_atoms']).ravel() 

162 self.setup_reference_hessians() # self._hessian_x and self._hessian_q 

163 

164 # store number of zero eigenvalues of G-matrix for redundancy check 

165 jac0 = self.get_jacobian(self.parameters['ref_atoms']) 

166 Gmat = jac0.T @ jac0 

167 self.Gmat_eigvals, _ = eigh(Gmat) # stored for inspection purposes 

168 self.zero_eigvals = len(np.flatnonzero(np.abs(self.Gmat_eigvals) < 

169 self.parameters['zero_thresh'])) 

170 

171 @staticmethod 

172 def check_input(coord_functions, variable_orientation, cartesian): 

173 if None in coord_functions: 

174 if not all(func is None for func in coord_functions): 

175 msg = ('A user-defined coordinate system requires both ' 

176 '`get_q_from_x` and `get_jacobian`.') 

177 raise CalculatorSetupError(msg) 

178 if variable_orientation: 

179 msg = ('The use of `variable_orientation` requires a ' 

180 'user-defined, translationally and rotationally ' 

181 'invariant coordinate system.') 

182 raise CalculatorSetupError(msg) 

183 if not cartesian: 

184 msg = ('A user-defined coordinate system is required for ' 

185 'calculations with cartesian=False.') 

186 raise CalculatorSetupError(msg) 

187 

188 def setup_reference_hessians(self): 

189 """Prepare projector to project out constrained user-defined coordinates 

190 **q** from Hessian. Then do transformation to user-defined coordinates 

191 and back. Relevant literature: 

192 * Peng, C. et al. J. Comput. Chem. 1996, 17 (1), 49-56. 

193 * Baker, J. et al. J. Chem. Phys. 1996, 105 (1), 192–212.""" 

194 jac0 = self.get_jacobian( 

195 self.parameters['ref_atoms']) # Jacobian (dq/dx) 

196 jac0 = self.constrain_jac(jac0) # for reference Cartesian coordinates 

197 ijac0 = self.get_ijac(jac0, self.parameters['rcond']) 

198 self.transform2reference_hessians(jac0, ijac0) # perform projection 

199 

200 def constrain_jac(self, jac): 

201 """Procedure by Peng, Ayala, Schlegel and Frisch adjusted for redundant 

202 coordinates. 

203 Peng, C. et al. J. Comput. Chem. 1996, 17 (1), 49–56. 

204 """ 

205 proj = jac @ jac.T # build non-redundant projector 

206 constrained_q = self.parameters['constrained_q'] or [] 

207 Cmat = np.zeros(proj.shape) # build projector for constraints 

208 Cmat[constrained_q, constrained_q] = 1.0 

209 proj = proj - proj @ Cmat @ pinv(Cmat @ proj @ Cmat) @ Cmat @ proj 

210 jac = pinv(jac) @ proj # come back to redundant projector 

211 return jac.T 

212 

213 def transform2reference_hessians(self, jac0, ijac0): 

214 """Transform Cartesian Hessian matrix to user-defined coordinates 

215 and back to Cartesian coordinates. For suitable coordinate systems 

216 (e.g. internals) this removes rotational and translational degrees of 

217 freedom. Furthermore, apply the lower limit to the force constants 

218 and reconstruct Hessian matrix.""" 

219 hessian_x = self.parameters['hessian_x'] 

220 hessian_x = 0.5 * (hessian_x + hessian_x.T) # guarantee symmetry 

221 hessian_q = ijac0.T @ hessian_x @ ijac0 # forward transformation 

222 hessian_x = jac0.T @ hessian_q @ jac0 # backward transformation 

223 hessian_x = 0.5 * (hessian_x + hessian_x.T) # guarantee symmetry 

224 w, v = eigh(hessian_x) # rot. and trans. degrees of freedom are removed 

225 w[np.abs(w) < self.parameters['zero_thresh']] = 0.0 # noise-cancelling 

226 w[(w > 0.0) & (w < self.parameters['hessian_limit'])] = self.parameters[ 

227 'hessian_limit' 

228 ] 

229 # reconstruct Hessian from new eigenvalues and preserved eigenvectors 

230 hessian_x = v @ np.diagflat(w) @ v.T # v.T == inv(v) due to symmetry 

231 self._hessian_x = 0.5 * (hessian_x + hessian_x.T) # guarantee symmetry 

232 self._hessian_q = ijac0.T @ self._hessian_x @ ijac0 

233 

234 @staticmethod 

235 def get_ijac(jac, rcond): # jac is the Wilson B-matrix 

236 """Compute Moore-Penrose pseudo-inverse of Wilson B-matrix.""" 

237 jac_T = jac.T # btw. direct Jacobian inversion is slow, hence form Gmat 

238 Gmat = jac_T @ jac # avoid: numpy.linalg.pinv(Gmat, rcond) @ jac_T 

239 ijac = lstsq(Gmat, jac_T, rcond, lapack_driver='gelsy') 

240 return ijac[0] # [-1] would be eigenvalues of Gmat 

241 

242 def get_energy_forces(self, atoms): 

243 """Return a tuple with energy and forces in Cartesian coordinates for 

244 a given :class:`~ase.Atoms` object.""" 

245 q = self.get_q_from_x(atoms).ravel() 

246 

247 if self.parameters['cartesian']: 

248 x = atoms.get_positions().ravel() 

249 x0 = self.x0 

250 hessian_x = self._hessian_x 

251 

252 if self.parameters['variable_orientation']: 

253 # determine x0 for present orientation 

254 x0 = self.back_transform(x, q, self.q0, atoms.copy()) 

255 ref_atoms = atoms.copy() 

256 ref_atoms.set_positions(x0.reshape(int(len(x0) / 3), 3)) 

257 x0 = ref_atoms.get_positions().ravel() 

258 # determine jac0 for present orientation 

259 jac0 = self.get_jacobian(ref_atoms) 

260 self.check_redundancy(jac0) # check for coordinate failure 

261 # determine hessian_x for present orientation 

262 hessian_x = jac0.T @ self._hessian_q @ jac0 

263 

264 xdiff = x - x0 

265 forces_x = -hessian_x @ xdiff 

266 energy = -0.5 * (forces_x * xdiff).sum() 

267 

268 else: 

269 jac = self.get_jacobian(atoms) 

270 self.check_redundancy(jac) # check for coordinate failure 

271 qdiff = q - self.q0 

272 forces_q = -self._hessian_q @ qdiff 

273 forces_x = forces_q @ jac 

274 energy = -0.5 * (forces_q * qdiff).sum() 

275 

276 energy += self.parameters['ref_energy'] 

277 forces_x = forces_x.reshape(int(forces_x.size / 3), 3) 

278 return energy, forces_x 

279 

280 def back_transform(self, x, q, q0, atoms_copy): 

281 """Find the right orientation in Cartesian reference coordinates.""" 

282 xk = 1 * x 

283 qk = 1 * q 

284 dq = qk - q0 

285 err = abs(dq).max() 

286 count = 0 

287 atoms_copy.set_constraint() # helpful for back-transformation 

288 while err > 1e-7: # back-transformation tolerance for convergence 

289 count += 1 

290 if count > 99: # maximum number of iterations during back-transf. 

291 msg = ('Back-transformation from user-defined to Cartesian ' 

292 'coordinates failed.') 

293 raise CalculationFailed(msg) 

294 jac = self.get_jacobian(atoms_copy) 

295 ijac = self.get_ijac(jac, self.parameters['rcond']) 

296 dx = ijac @ dq 

297 xk = xk - dx 

298 atoms_copy.set_positions(xk.reshape(int(len(xk) / 3), 3)) 

299 qk = self.get_q_from_x(atoms_copy).ravel() 

300 dq = qk - q0 

301 err = abs(dq).max() 

302 return xk 

303 

304 def check_redundancy(self, jac): 

305 """Compare number of zero eigenvalues of G-matrix to initial number.""" 

306 Gmat = jac.T @ jac 

307 self.Gmat_eigvals, _ = eigh(Gmat) 

308 zero_eigvals = len(np.flatnonzero(np.abs(self.Gmat_eigvals) < 

309 self.parameters['zero_thresh'])) 

310 if zero_eigvals != self.zero_eigvals: 

311 raise CalculationFailed('Suspected coordinate failure: ' 

312 f'G-matrix has got {zero_eigvals} ' 

313 'zero eigenvalues, but had ' 

314 f'{self.zero_eigvals} during setup') 

315 

316 @property 

317 def hessian_x(self): 

318 return self._hessian_x 

319 

320 @property 

321 def hessian_q(self): 

322 return self._hessian_q 

323 

324 

325class SpringCalculator(Calculator): 

326 """ 

327 Spring calculator corresponding to independent oscillators with a fixed 

328 spring constant. 

329 

330 

331 Energy for an atom is given as 

332 

333 E = k / 2 * (r - r_0)**2 

334 

335 where k is the spring constant and, r_0 the ideal positions. 

336 

337 

338 Parameters 

339 ---------- 

340 ideal_positions : array 

341 array of the ideal crystal positions 

342 k : float 

343 spring constant in eV/Angstrom 

344 """ 

345 implemented_properties = ['forces', 'energy', 'free_energy'] 

346 

347 def __init__(self, ideal_positions, k): 

348 Calculator.__init__(self) 

349 self.ideal_positions = ideal_positions.copy() 

350 self.k = k 

351 

352 def calculate(self, atoms=None, properties=['energy'], 

353 system_changes=all_changes): 

354 Calculator.calculate(self, atoms, properties, system_changes) 

355 energy, forces = self.compute_energy_and_forces(atoms) 

356 self.results['energy'], self.results['forces'] = energy, forces 

357 

358 def compute_energy_and_forces(self, atoms): 

359 disps = atoms.positions - self.ideal_positions 

360 forces = - self.k * disps 

361 energy = sum(self.k / 2.0 * norm(disps, axis=1)**2) 

362 return energy, forces 

363 

364 def get_free_energy(self, T, method='classical'): 

365 """Get analytic vibrational free energy for the spring system. 

366 

367 Parameters 

368 ---------- 

369 T : float 

370 temperature (K) 

371 method : str 

372 method for free energy computation; 'classical' or 'QM'. 

373 """ 

374 F = 0.0 

375 masses, counts = np.unique(self.atoms.get_masses(), return_counts=True) 

376 for m, c in zip(masses, counts): 

377 F += c * \ 

378 SpringCalculator.compute_Einstein_solid_free_energy( 

379 self.k, m, T, method) 

380 return F 

381 

382 @staticmethod 

383 def compute_Einstein_solid_free_energy(k, m, T, method='classical'): 

384 """ Get free energy (per atom) for an Einstein crystal. 

385 

386 Free energy of a Einstein solid given by classical (1) or QM (2) 

387 1. F_E = 3NkbT log( hw/kbT ) 

388 2. F_E = 3NkbT log( 1-exp(hw/kbT) ) + zeropoint 

389 

390 Parameters 

391 ----------- 

392 k : float 

393 spring constant (eV/A^2) 

394 m : float 

395 mass (grams/mole or AMU) 

396 T : float 

397 temperature (K) 

398 method : str 

399 method for free energy computation, classical or QM. 

400 

401 Returns 

402 -------- 

403 float 

404 free energy of the Einstein crystal (eV/atom) 

405 """ 

406 assert method in ['classical', 'QM'] 

407 

408 hbar = units._hbar * units.J # eV/s 

409 m = m / units.kg # mass kg 

410 k = k * units.m**2 / units.J # spring constant J/m2 

411 omega = np.sqrt(k / m) # angular frequency 1/s 

412 

413 if method == 'classical': 

414 F_einstein = 3 * units.kB * T * \ 

415 np.log(hbar * omega / (units.kB * T)) 

416 elif method == 'QM': 

417 log_factor = np.log(1.0 - np.exp(-hbar * omega / (units.kB * T))) 

418 F_einstein = 3 * units.kB * T * log_factor + 1.5 * hbar * omega 

419 

420 return F_einstein