Coverage for ase / calculators / tersoff.py: 95.26%

232 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-30 08:22 +0000

1from dataclasses import dataclass 

2from pathlib import Path 

3 

4import numpy as np 

5 

6from ase.calculators.calculator import Calculator, all_changes 

7from ase.neighborlist import NeighborList 

8from ase.stress import full_3x3_to_voigt_6_stress 

9 

10__author__ = 'Stefan Bringuier <stefanbringuier@gmail.com>' 

11__description__ = 'LAMMPS-style native Tersoff potential for ASE' 

12 

13# Maximum/minimum exponents for numerical stability 

14# in bond order calculation 

15_MAX_EXP_ARG = 69.0776e0 

16_MIN_EXP_ARG = -69.0776e0 

17 

18 

19@dataclass 

20class TersoffParameters: 

21 """Parameters for 3 element Tersoff potential interaction. 

22 

23 Can be instantiated with either positional or keyword arguments: 

24 TersoffParameters(1.0, 2.0, ...) or 

25 TersoffParameters(m=1.0, gamma=2.0, ...) 

26 """ 

27 

28 m: float 

29 gamma: float 

30 lambda3: float 

31 c: float 

32 d: float 

33 h: float 

34 n: float 

35 beta: float 

36 lambda2: float 

37 B: float 

38 R: float 

39 D: float 

40 lambda1: float 

41 A: float 

42 

43 @classmethod 

44 def from_list(cls, params: list[float]) -> 'TersoffParameters': 

45 """Create TersoffParameters from a list of 14 parameter values.""" 

46 if len(params) != 14: 

47 raise ValueError(f'Expected 14 parameters, got {len(params)}') 

48 return cls(*map(float, params)) 

49 

50 

51class Tersoff(Calculator): 

52 """ASE Calculator for Tersoff interatomic potential. 

53 

54 .. versionadded:: 3.25.0 

55 """ 

56 

57 implemented_properties = [ 

58 'free_energy', 

59 'energy', 

60 'energies', 

61 'forces', 

62 'stress', 

63 ] 

64 

65 def __init__( 

66 self, 

67 parameters: dict[tuple[str, str, str], TersoffParameters], 

68 skin: float = 0.3, 

69 **kwargs, 

70 ) -> None: 

71 """ 

72 Parameters 

73 ---------- 

74 parameters : dict 

75 Mapping element combinations to TersoffParameters objects:: 

76 

77 { 

78 ('A', 'B', 'C'): TersoffParameters( 

79 m, gamma, lambda3, c, d, h, n, 

80 beta, lambda2, B, R, D, lambda1, A), 

81 ... 

82 } 

83 

84 where ('A', 'B', 'C') are the elements involved in the interaction. 

85 skin : float, default 0.3 

86 The skin distance for neighbor list calculations. 

87 **kwargs : dict 

88 Additional parameters to be passed to 

89 :class:`~ase.calculators.Calculator`. 

90 

91 """ 

92 Calculator.__init__(self, **kwargs) 

93 self.cutoff_skin = skin 

94 self.parameters = parameters 

95 

96 @classmethod 

97 def from_lammps( 

98 cls: type['Tersoff'], 

99 potential_file: str | Path, 

100 skin: float = 0.3, 

101 **kwargs, 

102 ) -> 'Tersoff': 

103 """Make :class:`Tersoff` from a LAMMPS-style Tersoff potential file. 

104 

105 Parameters 

106 ---------- 

107 potential_file : str or Path 

108 The path to a LAMMPS-style Tersoff potential file. 

109 skin : float, default 0.3 

110 The skin distance for neighbor list calculations. 

111 **kwargs : dict 

112 Additional parameters to be passed to the 

113 ASE Calculator constructor. 

114 

115 Returns 

116 ------- 

117 :class:`Tersoff` 

118 Initialized Tersoff calculator with parameters from the file. 

119 

120 """ 

121 parameters = cls.read_lammps_format(potential_file) 

122 return cls(parameters=parameters, skin=skin, **kwargs) 

123 

124 @staticmethod 

125 def read_lammps_format( 

126 potential_file: str | Path, 

127 ) -> dict[tuple[str, str, str], TersoffParameters]: 

128 """Read the Tersoff potential parameters from a LAMMPS-style file. 

129 

130 Parameters 

131 ---------- 

132 potential_file : str or Path 

133 Path to the LAMMPS-style Tersoff potential file 

134 

135 Returns 

136 ------- 

137 dict 

138 Dictionary mapping element combinations to TersoffParameters objects 

139 

140 """ 

141 block_size = 17 

142 with Path(potential_file).open('r', encoding='utf-8') as fd: 

143 content = ( 

144 ''.join( 

145 [line for line in fd if not line.strip().startswith('#')] 

146 ) 

147 .replace('\n', ' ') 

148 .split() 

149 ) 

150 

151 if len(content) % block_size != 0: 

152 raise ValueError( 

153 'The potential file does not have the correct LAMMPS format.' 

154 ) 

155 

156 parameters: dict[tuple[str, str, str], TersoffParameters] = {} 

157 for i in range(0, len(content), block_size): 

158 block = content[i : i + block_size] 

159 e1, e2, e3 = block[0], block[1], block[2] 

160 current_elements = (e1, e2, e3) 

161 params = map(float, block[3:]) 

162 parameters[current_elements] = TersoffParameters(*params) 

163 

164 return parameters 

165 

166 def set_parameters( 

167 self, 

168 key: tuple[str, str, str], 

169 params: TersoffParameters | None = None, 

170 **kwargs, 

171 ) -> None: 

172 """Update parameters for a specific element combination. 

173 

174 Parameters 

175 ---------- 

176 key: Tuple[str, str, str] 

177 The element combination key of the parameters to be updated 

178 params: TersoffParameters, optional 

179 A TersoffParameters instance to completely replace the parameters 

180 **kwargs: 

181 Individual parameter values to update, e.g. R=2.9 

182 

183 """ 

184 if key not in self.parameters: 

185 raise KeyError(f"Key '{key}' not found in parameters.") 

186 

187 if params is not None: 

188 if kwargs: 

189 raise ValueError('Cannot provide both params and kwargs.') 

190 self.parameters[key] = params 

191 else: 

192 for name, value in kwargs.items(): 

193 if not hasattr(self.parameters[key], name): 

194 raise ValueError(f'Invalid parameter name: {name}') 

195 setattr(self.parameters[key], name, value) 

196 

197 def _update_nl(self, atoms) -> None: 

198 """Update the neighbor list with the parameter R+D cutoffs. 

199 

200 Parameters 

201 ---------- 

202 atoms: ase.Atoms 

203 The atoms to calculate the neighbor list for. 

204 

205 Notes 

206 ----- 

207 The cutoffs are determined by the parameters of the Tersoff potential. 

208 Each atom's cutoff is based on the R+D values from the parameter set 

209 where that atom's element appears first in the key tuple. 

210 

211 """ 

212 # Get cutoff for each atom based on its element type 

213 cutoffs = [] 

214 

215 for symbol in atoms.symbols: 

216 # Find first parameter set, element is the first slot 

217 param_key = next( 

218 key for key in self.parameters.keys() if key[0] == symbol 

219 ) 

220 params = self.parameters[param_key] 

221 cutoffs.append(params.R + params.D) 

222 

223 self.nl = NeighborList( 

224 cutoffs, 

225 skin=self.cutoff_skin, 

226 self_interaction=False, 

227 bothways=True, 

228 ) 

229 

230 self.nl.update(atoms) 

231 

232 def calculate( 

233 self, 

234 atoms=None, 

235 properties=None, 

236 system_changes=all_changes, 

237 ) -> None: 

238 """Calculate energy, forces, and stress. 

239 

240 Notes 

241 ----- 

242 The force and stress are calculated regardless if they are 

243 requested, despite some additional overhead cost, 

244 therefore they are always stored in the results dict. 

245 

246 """ 

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

248 

249 # Rebuild neighbor list when any relevant system changes occur 

250 checks = {'positions', 'numbers', 'cell', 'pbc'} 

251 if any(change in checks for change in system_changes) or not hasattr( 

252 self, 'nl' 

253 ): 

254 self._update_nl(atoms) 

255 

256 self.results = {} 

257 energies = np.zeros(len(atoms)) 

258 forces = np.zeros((len(atoms), 3)) 

259 virial = np.zeros((3, 3)) 

260 

261 # Duplicates atoms.get_distances() functionality, but uses 

262 # neighbor list's pre-computed offsets for efficiency in a 

263 # tight force-calculation loop rather than recompute MIC 

264 for i in range(len(atoms)): 

265 self._calc_atom_contribution(i, energies, forces, virial) 

266 

267 self.results['energies'] = energies 

268 self.results['energy'] = self.results['free_energy'] = energies.sum() 

269 self.results['forces'] = forces 

270 # Virial to stress (i.e., eV/A^3) 

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

272 stress = virial / self.atoms.get_volume() 

273 self.results['stress'] = full_3x3_to_voigt_6_stress(stress) 

274 

275 def _calc_atom_contribution( 

276 self, 

277 idx_i: int, 

278 energies: np.ndarray, 

279 forces: np.ndarray, 

280 virial: np.ndarray, 

281 ) -> None: 

282 """Calculate the contributions of a single atom to the properties. 

283 

284 This function calculates the energy, force, and stress on atom i 

285 by looking at i-j pair interactions and the modification made by 

286 the bond order term bij with includes 3-body interaction i-j-k. 

287 

288 Parameters 

289 ---------- 

290 idx_i: int 

291 Index of atom i 

292 energies: array_like 

293 Site energies to be updated. 

294 forces: array_like 

295 Forces to be updated. 

296 virial: array_like 

297 Virial tensor to be updated. 

298 

299 """ 

300 indices, offsets = self.nl.get_neighbors(idx_i) 

301 vectors = self.atoms.positions[indices] 

302 vectors += offsets @ self.atoms.cell 

303 vectors -= self.atoms.positions[idx_i] 

304 distances = np.sqrt(np.add.reduce(vectors**2, axis=1)) 

305 

306 type_i = self.atoms.symbols[idx_i] 

307 for j, (idx_j, abs_rij, rij) in enumerate( 

308 zip(indices, distances, vectors) 

309 ): 

310 type_j = self.atoms.symbols[idx_j] 

311 key = (type_i, type_j, type_j) 

312 params = self.parameters[key] 

313 

314 rij_hat = rij / abs_rij 

315 

316 fc = self._calc_fc(abs_rij, params.R, params.D) 

317 if fc == 0.0: 

318 continue 

319 

320 zeta = self._calc_zeta(type_i, j, indices, distances, vectors) 

321 bij = self._calc_bij(zeta, params.beta, params.n) 

322 bij_d = self._calc_bij_d(zeta, params.beta, params.n) 

323 

324 repulsive = params.A * np.exp(-params.lambda1 * abs_rij) 

325 attractive = -params.B * np.exp(-params.lambda2 * abs_rij) 

326 

327 # distribute the pair energy evenly to be consistent with LAMMPS 

328 energies[idx_i] += 0.25 * fc * (repulsive + bij * attractive) 

329 energies[idx_j] += 0.25 * fc * (repulsive + bij * attractive) 

330 

331 dfc = self._calc_fc_d(abs_rij, params.R, params.D) 

332 rep_deriv = -params.lambda1 * repulsive 

333 att_deriv = -params.lambda2 * attractive 

334 

335 tmp = dfc * (repulsive + bij * attractive) 

336 tmp += fc * (rep_deriv + bij * att_deriv) 

337 

338 # derivative with respect to the position of atom j 

339 grad = 0.5 * tmp * rij_hat 

340 

341 forces[idx_i] += grad 

342 forces[idx_j] -= grad 

343 

344 virial += np.outer(grad, rij) 

345 

346 for k, idx_k in enumerate(indices): 

347 if k == j: 

348 continue 

349 

350 type_k = self.atoms.symbols[idx_k] 

351 key = (type_i, type_j, type_k) 

352 params = self.parameters[key] 

353 

354 if distances[k] > params.R + params.D: 

355 continue 

356 

357 rik = vectors[k] 

358 

359 dztdri, dztdrj, dztdrk = self._calc_zeta_d(rij, rik, params) 

360 

361 gradi = 0.5 * fc * bij_d * dztdri * attractive 

362 gradj = 0.5 * fc * bij_d * dztdrj * attractive 

363 gradk = 0.5 * fc * bij_d * dztdrk * attractive 

364 

365 forces[idx_i] -= gradi 

366 forces[idx_j] -= gradj 

367 forces[idx_k] -= gradk 

368 

369 virial += np.outer(gradj, rij) 

370 virial += np.outer(gradk, rik) 

371 

372 def _calc_bij(self, zeta: float, beta: float, n: float) -> float: 

373 """Calculate the bond order ``bij`` between atoms ``i`` and ``j``.""" 

374 tmp = beta * zeta 

375 return (1.0 + tmp**n) ** (-1.0 / (2.0 * n)) 

376 

377 def _calc_bij_d(self, zeta: float, beta: float, n: float) -> float: 

378 """Calculate the derivative of ``bij`` with respect to ``zeta``.""" 

379 tmp = beta * zeta 

380 return ( 

381 -0.5 

382 * (1.0 + tmp**n) ** (-1.0 - (1.0 / (2.0 * n))) 

383 * (beta * tmp ** (n - 1.0)) 

384 ) 

385 

386 def _calc_zeta( 

387 self, 

388 type_i: str, 

389 j: int, 

390 neighbors: np.ndarray, 

391 distances: np.ndarray, 

392 vectors: np.ndarray, 

393 ) -> float: 

394 """Calculate ``zeta_ij``.""" 

395 idx_j = neighbors[j] 

396 type_j = self.atoms.symbols[idx_j] 

397 abs_rij = distances[j] 

398 

399 zeta = 0.0 

400 

401 for k, idx_k in enumerate(neighbors): 

402 if k == j: 

403 continue 

404 

405 type_k = self.atoms.symbols[idx_k] 

406 key = (type_i, type_j, type_k) 

407 params = self.parameters[key] 

408 

409 abs_rik = distances[k] 

410 if abs_rik > params.R + params.D: 

411 continue 

412 

413 costheta = np.dot(vectors[j], vectors[k]) / (abs_rij * abs_rik) 

414 fc_ik = self._calc_fc(abs_rik, params.R, params.D) 

415 

416 g_theta = self._calc_gijk(costheta, params) 

417 

418 # Calculate the exponential for the bond order zeta term 

419 # This is the term that modifies the bond order based 

420 # on the distance between atoms i-j and i-k. Tresholds are 

421 # used to prevent overflow/underflow. 

422 arg = (params.lambda3 * (abs_rij - abs_rik)) ** params.m 

423 if arg > _MAX_EXP_ARG: 

424 ex_delr = 1.0e30 

425 elif arg < _MIN_EXP_ARG: 

426 ex_delr = 0.0 

427 else: 

428 ex_delr = np.exp(arg) 

429 

430 zeta += fc_ik * g_theta * ex_delr 

431 

432 return zeta 

433 

434 def _calc_gijk(self, costheta: float, params: TersoffParameters) -> float: 

435 r"""Calculate the angular function ``g`` for the Tersoff potential. 

436 

437 .. math:: 

438 g(\theta) = \gamma \left( 1 + \frac{c^2}{d^2} 

439 - \frac{c^2}{d^2 + (h - \cos \theta)^2} \right) 

440 

441 where :math:`\theta` is the angle between the bond vector 

442 and the vector of atom i and its neighbors j-k. 

443 """ 

444 c2 = params.c * params.c 

445 d2 = params.d * params.d 

446 hcth = params.h - costheta 

447 return params.gamma * (1.0 + c2 / d2 - c2 / (d2 + hcth**2)) 

448 

449 def _calc_gijk_d(self, costheta: float, params: TersoffParameters) -> float: 

450 """Calculate the derivative of ``g`` with respect to ``costheta``.""" 

451 c2 = params.c * params.c 

452 d2 = params.d * params.d 

453 hcth = params.h - costheta 

454 numerator = -2.0 * params.gamma * c2 * hcth 

455 denominator = (d2 + hcth**2) ** 2 

456 return numerator / denominator 

457 

458 def _calc_fc(self, r: np.floating, R: float, D: float) -> float: 

459 """Calculate the cutoff function.""" 

460 if r > R + D: 

461 return 0.0 

462 if r < R - D: 

463 return 1.0 

464 return 0.5 * (1.0 - np.sin(np.pi * (r - R) / (2.0 * D))) 

465 

466 def _calc_fc_d(self, r: np.floating, R: float, D: float) -> float: 

467 """Calculate cutoff function derivative with respect to ``r``.""" 

468 if r > R + D or r < R - D: 

469 return 0.0 

470 return -0.25 * np.pi / D * np.cos(np.pi * (r - R) / (2.0 * D)) 

471 

472 def _calc_zeta_d( 

473 self, 

474 rij: np.ndarray, 

475 rik: np.ndarray, 

476 params: TersoffParameters, 

477 ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: 

478 """Calculate the derivatives of ``zeta``. 

479 

480 Returns 

481 ------- 

482 dri : ndarray of shape (3,), dtype float 

483 Derivative with respect to the position of atom ``i``. 

484 drj : ndarray of shape (3,), dtype float 

485 Derivative with respect to the position of atom ``j``. 

486 drk : ndarray of shape (3,), dtype float 

487 Derivative with respect to the position of atom ``k``. 

488 

489 """ 

490 lam3 = params.lambda3 

491 m = params.m 

492 

493 abs_rij = np.linalg.norm(rij) 

494 abs_rik = np.linalg.norm(rik) 

495 

496 rij_hat = rij / abs_rij 

497 rik_hat = rik / abs_rik 

498 

499 fcik = self._calc_fc(abs_rik, params.R, params.D) 

500 dfcik = self._calc_fc_d(abs_rik, params.R, params.D) 

501 

502 tmp = (lam3 * (abs_rij - abs_rik)) ** m 

503 if tmp > _MAX_EXP_ARG: 

504 ex_delr = 1.0e30 

505 elif tmp < _MIN_EXP_ARG: 

506 ex_delr = 0.0 

507 else: 

508 ex_delr = np.exp(tmp) 

509 

510 ex_delr_d = m * lam3**m * (abs_rij - abs_rik) ** (m - 1) * ex_delr 

511 

512 costheta = rij_hat @ rik_hat 

513 gijk = self._calc_gijk(costheta, params) 

514 gijk_d = self._calc_gijk_d(costheta, params) 

515 

516 dcosdri, dcosdrj, dcosdrk = self._calc_costheta_d(rij, rik) 

517 

518 dri = -dfcik * gijk * ex_delr * rik_hat 

519 dri += fcik * gijk_d * ex_delr * dcosdri 

520 dri += fcik * gijk * ex_delr_d * rik_hat 

521 dri -= fcik * gijk * ex_delr_d * rij_hat 

522 

523 drj = fcik * gijk_d * ex_delr * dcosdrj 

524 drj += fcik * gijk * ex_delr_d * rij_hat 

525 

526 drk = dfcik * gijk * ex_delr * rik_hat 

527 drk += fcik * gijk_d * ex_delr * dcosdrk 

528 drk -= fcik * gijk * ex_delr_d * rik_hat 

529 

530 return dri, drj, drk 

531 

532 def _calc_costheta_d( 

533 self, 

534 rij: np.ndarray, 

535 rik: np.ndarray, 

536 ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: 

537 r"""Calculate the derivatives of ``costheta``. 

538 

539 If 

540 

541 .. math:: 

542 \cos \theta = \frac{\mathbf{u} \cdot \mathbf{v}}{u v} 

543 

544 Then 

545 

546 .. math:: 

547 \frac{\partial \cos \theta}{\partial \mathbf{u}} 

548 = \frac{\mathbf{v}}{u v} 

549 - \frac{\mathbf{u} \cdot \mathbf{v}}{v} \cdot \frac{\mathbf{u}}{u^3} 

550 = \frac{\mathbf{v}}{u v} - \frac{\cos \theta}{u^2} \mathbf{u} 

551 

552 Parameters 

553 ---------- 

554 rij : ndarray of shape (3,), dtype float 

555 Vector from atoms ``i`` to ``j``. 

556 rik : ndarray of shape (3,), dtype float 

557 Vector from atoms ``i`` to ``k``. 

558 

559 Returns 

560 ------- 

561 dri : ndarray of shape (3,), dtype float 

562 Derivative with respect to the position of atom ``i``. 

563 drj : ndarray of shape (3,), dtype float 

564 Derivative with respect to the position of atom ``j``. 

565 drk : ndarray of shape (3,), dtype float 

566 Derivative with respect to the position of atom ``k``. 

567 

568 """ 

569 abs_rij = np.linalg.norm(rij) 

570 abs_rik = np.linalg.norm(rik) 

571 costheta = (rij @ rik) / (abs_rij * abs_rik) 

572 drj = (rik / abs_rik - costheta * rij / abs_rij) / abs_rij 

573 drk = (rij / abs_rij - costheta * rik / abs_rik) / abs_rik 

574 dri = -(drj + drk) 

575 return dri, drj, drk