Coverage for /builds/ase/ase/ase/calculators/lj.py: 100.00%

69 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2025-08-02 00:12 +0000

1# fmt: off 

2 

3import numpy as np 

4 

5from ase.calculators.calculator import Calculator, all_changes 

6from ase.neighborlist import NeighborList 

7from ase.stress import full_3x3_to_voigt_6_stress 

8 

9 

10class LennardJones(Calculator): 

11 """Lennard Jones potential calculator 

12 

13 see https://en.wikipedia.org/wiki/Lennard-Jones_potential 

14 

15 The fundamental definition of this potential is a pairwise energy: 

16 

17 ``u_ij = 4 epsilon ( sigma^12/r_ij^12 - sigma^6/r_ij^6 )`` 

18 

19 For convenience, we'll use d_ij to refer to "distance vector" and 

20 ``r_ij`` to refer to "scalar distance". So, with position vectors `r_i`: 

21 

22 ``r_ij = | r_j - r_i | = | d_ij |`` 

23 

24 Therefore: 

25 

26 ``d r_ij / d d_ij = + d_ij / r_ij`` 

27 ``d r_ij / d d_i = - d_ij / r_ij`` 

28 

29 The derivative of u_ij is: 

30 

31 :: 

32 

33 d u_ij / d r_ij 

34 = (-24 epsilon / r_ij) ( 2 sigma^12/r_ij^12 - sigma^6/r_ij^6 ) 

35 

36 We can define a "pairwise force" 

37 

38 ``f_ij = d u_ij / d d_ij = d u_ij / d r_ij * d_ij / r_ij`` 

39 

40 The terms in front of d_ij are combined into a "general derivative". 

41 

42 ``du_ij = (d u_ij / d d_ij) / r_ij`` 

43 

44 We do this for convenience: `du_ij` is purely scalar The pairwise force is: 

45 

46 ``f_ij = du_ij * d_ij`` 

47 

48 The total force on an atom is: 

49 

50 ``f_i = sum_(j != i) f_ij`` 

51 

52 There is some freedom of choice in assigning atomic energies, i.e. 

53 choosing a way to partition the total energy into atomic contributions. 

54 

55 We choose a symmetric approach (`bothways=True` in the neighbor list): 

56 

57 ``u_i = 1/2 sum_(j != i) u_ij`` 

58 

59 The total energy of a system of atoms is then: 

60 

61 ``u = sum_i u_i = 1/2 sum_(i, j != i) u_ij`` 

62 

63 Differentiating `u` with respect to `r_i` yields the force, 

64 independent of the choice of partitioning. 

65 

66 :: 

67 

68 f_i = - d u / d r_i = - sum_ij d u_ij / d r_i 

69 = - sum_ij d u_ij / d r_ij * d r_ij / d r_i 

70 = sum_ij du_ij d_ij = sum_ij f_ij 

71 

72 This justifies calling `f_ij` pairwise forces. 

73 

74 The stress can be written as ( `(x)` denoting outer product): 

75 

76 ``sigma = 1/2 sum_(i, j != i) f_ij (x) d_ij = sum_i sigma_i ,`` 

77 with atomic contributions 

78 

79 ``sigma_i = 1/2 sum_(j != i) f_ij (x) d_ij`` 

80 

81 Another consideration is the cutoff. We have to ensure that the potential 

82 goes to zero smoothly as an atom moves across the cutoff threshold, 

83 otherwise the potential is not continuous. In cases where the cutoff is 

84 so large that u_ij is very small at the cutoff this is automatically 

85 ensured, but in general, `u_ij(rc) != 0`. 

86 

87 This implementation offers two ways to deal with this: 

88 

89 Either, we shift the pairwise energy 

90 

91 ``u'_ij = u_ij - u_ij(rc)`` 

92 

93 which ensures that it is precisely zero at the cutoff. However, this means 

94 that the energy effectively depends on the cutoff, which might lead to 

95 unexpected results! If this option is chosen, the forces discontinuously 

96 jump to zero at the cutoff. 

97 

98 An alternative is to modify the pairwise potential by multiplying 

99 it with a cutoff function that goes from 1 to 0 between an onset radius 

100 ro and the cutoff rc. If the function is chosen suitably, it can also 

101 smoothly push the forces down to zero, ensuring continuous forces as well. 

102 In order for this to work well, the onset radius has to be set suitably, 

103 typically around 2*sigma. 

104 

105 In this case, we introduce a modified pairwise potential: 

106 

107 ``u'_ij = fc * u_ij`` 

108 

109 The pairwise forces have to be modified accordingly: 

110 

111 ``f'_ij = fc * f_ij + fc' * u_ij`` 

112 

113 Where `fc' = d fc / d d_ij`. 

114 

115 This approach is taken from Jax-MD (https://github.com/google/jax-md), 

116 which in turn is inspired by HOOMD Blue 

117 (https://glotzerlab.engin.umich.edu/hoomd-blue/). 

118 

119 """ 

120 

121 implemented_properties = ['energy', 'energies', 'forces', 'free_energy'] 

122 implemented_properties += ['stress', 'stresses'] # bulk properties 

123 default_parameters = { 

124 'epsilon': 1.0, 

125 'sigma': 1.0, 

126 'rc': None, 

127 'ro': None, 

128 'smooth': False, 

129 } 

130 nolabel = True 

131 

132 def __init__(self, **kwargs): 

133 """ 

134 Parameters 

135 ---------- 

136 sigma: float 

137 The potential minimum is at 2**(1/6) * sigma, default 1.0 

138 epsilon: float 

139 The potential depth, default 1.0 

140 rc: float, None 

141 Cut-off for the NeighborList is set to 3 * sigma if None. 

142 The energy is upshifted to be continuous at rc. 

143 Default None 

144 ro: float, None 

145 Onset of cutoff function in 'smooth' mode. Defaults to 0.66 * rc. 

146 smooth: bool, False 

147 Cutoff mode. False means that the pairwise energy is simply shifted 

148 to be 0 at r = rc, leading to the energy going to 0 continuously, 

149 but the forces jumping to zero discontinuously at the cutoff. 

150 True means that a smooth cutoff function is multiplied to the pairwise 

151 energy that smoothly goes to 0 between ro and rc. Both energy and 

152 forces are continuous in that case. 

153 If smooth=True, make sure to check the tail of the 

154 forces for kinks, ro might have to be adjusted to avoid distorting 

155 the potential too much. 

156 

157 """ 

158 

159 Calculator.__init__(self, **kwargs) 

160 

161 if self.parameters.rc is None: 

162 self.parameters.rc = 3 * self.parameters.sigma 

163 

164 if self.parameters.ro is None: 

165 self.parameters.ro = 0.66 * self.parameters.rc 

166 

167 self.nl = None 

168 

169 def calculate( 

170 self, 

171 atoms=None, 

172 properties=None, 

173 system_changes=all_changes, 

174 ): 

175 if properties is None: 

176 properties = self.implemented_properties 

177 

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

179 

180 natoms = len(self.atoms) 

181 

182 sigma = self.parameters.sigma 

183 epsilon = self.parameters.epsilon 

184 rc = self.parameters.rc 

185 ro = self.parameters.ro 

186 smooth = self.parameters.smooth 

187 

188 if self.nl is None or 'numbers' in system_changes: 

189 self.nl = NeighborList( 

190 [rc / 2] * natoms, self_interaction=False, bothways=True 

191 ) 

192 

193 self.nl.update(self.atoms) 

194 

195 positions = self.atoms.positions 

196 cell = self.atoms.cell 

197 

198 # potential value at rc 

199 e0 = 4 * epsilon * ((sigma / rc) ** 12 - (sigma / rc) ** 6) 

200 

201 energies = np.zeros(natoms) 

202 forces = np.zeros((natoms, 3)) 

203 stresses = np.zeros((natoms, 3, 3)) 

204 

205 for ii in range(natoms): 

206 neighbors, offsets = self.nl.get_neighbors(ii) 

207 cells = np.dot(offsets, cell) 

208 

209 # pointing *towards* neighbours 

210 distance_vectors = positions[neighbors] + cells - positions[ii] 

211 

212 r2 = (distance_vectors ** 2).sum(1) 

213 c6 = (sigma ** 2 / r2) ** 3 

214 c6[r2 > rc ** 2] = 0.0 

215 c12 = c6 ** 2 

216 

217 if smooth: 

218 cutoff_fn = cutoff_function(r2, rc**2, ro**2) 

219 d_cutoff_fn = d_cutoff_function(r2, rc**2, ro**2) 

220 

221 pairwise_energies = 4 * epsilon * (c12 - c6) 

222 pairwise_forces = -24 * epsilon * (2 * c12 - c6) / r2 # du_ij 

223 

224 if smooth: 

225 # order matters, otherwise the pairwise energy is already 

226 # modified 

227 pairwise_forces = ( 

228 cutoff_fn * pairwise_forces + 2 * d_cutoff_fn 

229 * pairwise_energies 

230 ) 

231 pairwise_energies *= cutoff_fn 

232 else: 

233 pairwise_energies -= e0 * (c6 != 0.0) 

234 

235 pairwise_forces = pairwise_forces[:, np.newaxis] * distance_vectors 

236 

237 energies[ii] += 0.5 * pairwise_energies.sum() # atomic energies 

238 forces[ii] += pairwise_forces.sum(axis=0) 

239 

240 stresses[ii] += 0.5 * np.dot( 

241 pairwise_forces.T, distance_vectors 

242 ) # equivalent to outer product 

243 

244 # no lattice, no stress 

245 if self.atoms.cell.rank == 3: 

246 stresses = full_3x3_to_voigt_6_stress(stresses) 

247 self.results['stress'] = stresses.sum( 

248 axis=0) / self.atoms.get_volume() 

249 self.results['stresses'] = stresses / self.atoms.get_volume() 

250 

251 energy = energies.sum() 

252 self.results['energy'] = energy 

253 self.results['energies'] = energies 

254 

255 self.results['free_energy'] = energy 

256 

257 self.results['forces'] = forces 

258 

259 

260def cutoff_function(r, rc, ro): 

261 """Smooth cutoff function. 

262 

263 Goes from 1 to 0 between ro and rc, ensuring 

264 that u(r) = lj(r) * cutoff_function(r) is C^1. 

265 

266 Defined as 1 below ro, 0 above rc. 

267 

268 Note that r, rc, ro are all expected to be squared, 

269 i.e. `r = r_ij^2`, etc. 

270 

271 Taken from https://github.com/google/jax-md. 

272 

273 """ 

274 

275 return np.where( 

276 r < ro, 

277 1.0, 

278 np.where(r < rc, (rc - r) ** 2 * (rc + 2 * 

279 r - 3 * ro) / (rc - ro) ** 3, 0.0), 

280 ) 

281 

282 

283def d_cutoff_function(r, rc, ro): 

284 """Derivative of smooth cutoff function wrt r. 

285 

286 Note that `r = r_ij^2`, so for the derivative wrt to `r_ij`, 

287 we need to multiply `2*r_ij`. This gives rise to the factor 2 

288 above, the `r_ij` is cancelled out by the remaining derivative 

289 `d r_ij / d d_ij`, i.e. going from scalar distance to distance vector. 

290 """ 

291 

292 return np.where( 

293 r < ro, 

294 0.0, 

295 np.where(r < rc, 6 * (rc - r) * (ro - r) / (rc - ro) ** 3, 0.0), 

296 )