Coverage for /builds/debichem-team/python-ase/ase/optimize/precon/precon.py: 85.19%

675 statements  

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

1""" 

2Implementation of the Precon abstract base class and subclasses 

3""" 

4 

5import copy 

6import sys 

7import time 

8import warnings 

9from abc import ABC, abstractmethod 

10 

11import numpy as np 

12from scipy import sparse 

13from scipy.interpolate import CubicSpline 

14from scipy.sparse.linalg import spsolve 

15 

16import ase.units as units 

17import ase.utils.ff as ff 

18from ase.constraints import FixAtoms 

19from ase.filters import Filter 

20from ase.geometry import find_mic 

21from ase.neighborlist import neighbor_list 

22from ase.optimize.precon.neighbors import ( 

23 estimate_nearest_neighbour_distance, 

24 get_neighbours, 

25) 

26from ase.utils import longsum, tokenize_version 

27 

28try: 

29 import pyamg 

30except ImportError: 

31 have_pyamg = False 

32else: 

33 have_pyamg = True 

34 

35 

36def create_pyamg_solver(P, max_levels=15): 

37 if not have_pyamg: 

38 raise RuntimeError( 

39 'pyamg not available: install with `pip install pyamg`') 

40 filter_key = 'filter' 

41 if tokenize_version(pyamg.__version__) >= tokenize_version('4.2.1'): 

42 filter_key = 'filter_entries' 

43 return pyamg.smoothed_aggregation_solver( 

44 P, B=None, 

45 strength=('symmetric', {'theta': 0.0}), 

46 smooth=( 

47 'jacobi', {filter_key: True, 'weighting': 'local'}), 

48 improve_candidates=([('block_gauss_seidel', 

49 {'sweep': 'symmetric', 'iterations': 4})] + 

50 [None] * (max_levels - 1)), 

51 aggregate='standard', 

52 presmoother=('block_gauss_seidel', 

53 {'sweep': 'symmetric', 'iterations': 1}), 

54 postsmoother=('block_gauss_seidel', 

55 {'sweep': 'symmetric', 'iterations': 1}), 

56 max_levels=max_levels, 

57 max_coarse=300, 

58 coarse_solver='pinv') 

59 

60 

61THz = 1e12 * 1. / units.s 

62 

63 

64class Precon(ABC): 

65 

66 @abstractmethod 

67 def make_precon(self, atoms, reinitialize=None): 

68 """ 

69 Create a preconditioner matrix based on the passed set of atoms. 

70 

71 Creates a general-purpose preconditioner for use with optimization 

72 algorithms, based on examining distances between pairs of atoms in the 

73 lattice. The matrix will be stored in the attribute self.P and 

74 returned. 

75 

76 Args: 

77 atoms: the Atoms object used to create the preconditioner. 

78 

79 reinitialize: if True, parameters of the preconditioner 

80 will be recalculated before the preconditioner matrix is 

81 created. If False, they will be calculated only when they 

82 do not currently have a value (ie, the first time this 

83 function is called). 

84 

85 Returns: 

86 P: A sparse scipy csr_matrix. BE AWARE that using 

87 numpy.dot() with sparse matrices will result in 

88 errors/incorrect results - use the .dot method directly 

89 on the matrix instead. 

90 """ 

91 ... 

92 

93 @abstractmethod 

94 def Pdot(self, x): 

95 """ 

96 Return the result of applying P to a vector x 

97 """ 

98 ... 

99 

100 def dot(self, x, y): 

101 """ 

102 Return the preconditioned dot product <P x, y> 

103 

104 Uses 128-bit floating point math for vector dot products 

105 """ 

106 return longsum(self.Pdot(x) * y) 

107 

108 def norm(self, x): 

109 """ 

110 Return the P-norm of x, where |x|_P = sqrt(<Px, x>) 

111 """ 

112 return np.sqrt(self.dot(x, x)) 

113 

114 def vdot(self, x, y): 

115 return self.dot(x.reshape(-1), 

116 y.reshape(-1)) 

117 

118 @abstractmethod 

119 def solve(self, x): 

120 """ 

121 Solve the (sparse) linear system P x = y and return y 

122 """ 

123 ... 

124 

125 def apply(self, forces, atoms): 

126 """ 

127 Convenience wrapper that combines make_precon() and solve() 

128 

129 Parameters 

130 ---------- 

131 forces: array 

132 (len(atoms)*3) array of input forces 

133 atoms: ase.atoms.Atoms 

134 

135 Returns 

136 ------- 

137 precon_forces: array 

138 (len(atoms), 3) array of preconditioned forces 

139 residual: float 

140 inf-norm of original forces, i.e. maximum absolute force 

141 """ 

142 self.make_precon(atoms) 

143 residual = np.linalg.norm(forces, np.inf) 

144 precon_forces = self.solve(forces) 

145 return precon_forces, residual 

146 

147 @abstractmethod 

148 def copy(self): 

149 ... 

150 

151 @abstractmethod 

152 def asarray(self): 

153 """ 

154 Array representation of preconditioner, as a dense matrix 

155 """ 

156 ... 

157 

158 

159class Logfile: 

160 def __init__(self, logfile=None): 

161 if isinstance(logfile, str): 

162 if logfile == "-": 

163 logfile = sys.stdout 

164 else: 

165 logfile = open(logfile, "a") 

166 self.logfile = logfile 

167 

168 def write(self, *args): 

169 if self.logfile is None: 

170 return 

171 self.logfile.write(*args) 

172 

173 

174class SparsePrecon(Precon): 

175 def __init__(self, r_cut=None, r_NN=None, 

176 mu=None, mu_c=None, 

177 dim=3, c_stab=0.1, force_stab=False, 

178 reinitialize=False, array_convention='C', 

179 solver="auto", solve_tol=1e-8, 

180 apply_positions=True, apply_cell=True, 

181 estimate_mu_eigmode=False, logfile=None, rng=None, 

182 neighbour_list=neighbor_list): 

183 """Initialise a preconditioner object based on passed parameters. 

184 

185 Parameters: 

186 r_cut: float. This is a cut-off radius. The preconditioner matrix 

187 will be created by considering pairs of atoms that are within a 

188 distance r_cut of each other. For a regular lattice, this is 

189 usually taken somewhere between the first- and second-nearest 

190 neighbour distance. If r_cut is not provided, default is 

191 2 * r_NN (see below) 

192 r_NN: nearest neighbour distance. If not provided, this is 

193 calculated 

194 from input structure. 

195 mu: float 

196 energy scale for position degreees of freedom. If `None`, mu 

197 is precomputed using finite difference derivatives. 

198 mu_c: float 

199 energy scale for cell degreees of freedom. Also precomputed 

200 if None. 

201 estimate_mu_eigmode: 

202 If True, estimates mu based on the lowest eigenmodes of 

203 unstabilised preconditioner. If False it uses the sine based 

204 approach. 

205 dim: int; dimensions of the problem 

206 c_stab: float. The diagonal of the preconditioner matrix will have 

207 a stabilisation constant added, which will be the value of 

208 c_stab times mu. 

209 force_stab: 

210 If True, always add the stabilisation to diagnonal, regardless 

211 of the presence of fixed atoms. 

212 reinitialize: if True, the value of mu will be recalculated when 

213 self.make_precon is called. This can be overridden in specific 

214 cases with reinitialize argument in self.make_precon. If it 

215 is set to True here, the value passed for mu will be 

216 irrelevant unless reinitialize is set to False the first time 

217 make_precon is called. 

218 array_convention: Either 'C' or 'F' for Fortran; this will change 

219 the preconditioner to reflect the ordering of the indices in 

220 the vector it will operate on. The C convention assumes the 

221 vector will be arranged atom-by-atom (ie [x1, y1, z1, x2, ...]) 

222 while the F convention assumes it will be arranged component 

223 by component (ie [x1, x2, ..., y1, y2, ...]). 

224 solver: One of "auto", "direct" or "pyamg", specifying whether to 

225 use a direct sparse solver or PyAMG to solve P x = y. 

226 Default is "auto" which uses PyAMG if available, falling 

227 back to sparse solver if not. solve_tol: tolerance used for 

228 PyAMG sparse linear solver, if available. 

229 apply_positions: bool 

230 if True, apply preconditioner to position DoF 

231 apply_cell: bool 

232 if True, apply preconditioner to cell DoF 

233 logfile: file object or str 

234 If *logfile* is a string, a file with that name will be opened. 

235 Use '-' for stdout. 

236 rng: None or np.random.RandomState instance 

237 Random number generator to use for initialising pyamg solver 

238 neighbor_list: function (optional). Optionally replace the built-in 

239 ASE neighbour list with an alternative with the same call 

240 signature, e.g. `matscipy.neighbours.neighbour_list`. 

241 

242 Raises: 

243 ValueError for problem with arguments 

244 

245 """ 

246 self.r_NN = r_NN 

247 self.r_cut = r_cut 

248 self.mu = mu 

249 self.mu_c = mu_c 

250 self.estimate_mu_eigmode = estimate_mu_eigmode 

251 self.c_stab = c_stab 

252 self.force_stab = force_stab 

253 self.array_convention = array_convention 

254 self.reinitialize = reinitialize 

255 self.P = None 

256 self.old_positions = None 

257 

258 use_pyamg = False 

259 if solver == "auto": 

260 use_pyamg = have_pyamg 

261 elif solver == "direct": 

262 use_pyamg = False 

263 elif solver == "pyamg": 

264 if not have_pyamg: 

265 raise RuntimeError("solver='pyamg', PyAMG can't be imported!") 

266 use_pyamg = True 

267 else: 

268 raise ValueError('unknown solver - ' 

269 'should be "auto", "direct" or "pyamg"') 

270 

271 self.use_pyamg = use_pyamg 

272 self.solve_tol = solve_tol 

273 self.apply_positions = apply_positions 

274 self.apply_cell = apply_cell 

275 

276 if dim < 1: 

277 raise ValueError('Dimension must be at least 1') 

278 self.dim = dim 

279 self.logfile = Logfile(logfile) 

280 

281 if rng is None: 

282 rng = np.random.RandomState() 

283 self.rng = rng 

284 

285 self.neighbor_list = neighbor_list 

286 

287 def copy(self): 

288 return copy.deepcopy(self) 

289 

290 def Pdot(self, x): 

291 return self.P.dot(x) 

292 

293 def solve(self, x): 

294 start_time = time.time() 

295 if self.use_pyamg and have_pyamg: 

296 y = self.ml.solve(x, x0=self.rng.random(self.P.shape[0]), 

297 tol=self.solve_tol, 

298 accel='cg', 

299 maxiter=300, 

300 cycle='W') 

301 else: 

302 y = spsolve(self.P, x) 

303 self.logfile.write( 

304 f'--- Precon applied in {(time.time() - start_time)} seconds ---\n') 

305 return y 

306 

307 def estimate_mu(self, atoms, H=None): 

308 r"""Estimate optimal preconditioner coefficient \mu 

309 

310 \mu is estimated from a numerical solution of 

311 

312 [dE(p+v) - dE(p)] \cdot v = \mu < P1 v, v > 

313 

314 with perturbation 

315 

316 v(x,y,z) = H P_lowest_nonzero_eigvec(x, y, z) 

317 

318 or 

319 

320 v(x,y,z) = H (sin(x / Lx), sin(y / Ly), sin(z / Lz)) 

321 

322 After the optimal \mu is found, self.mu will be set to its value. 

323 

324 If `atoms` is an instance of Filter an additional \mu_c 

325 will be computed for the cell degrees of freedom . 

326 

327 Args: 

328 atoms: Atoms object for initial system 

329 

330 H: 3x3 array or None 

331 Magnitude of deformation to apply. 

332 Default is 1e-2*rNN*np.eye(3) 

333 

334 Returns: 

335 mu : float 

336 mu_c : float or None 

337 """ 

338 logfile = self.logfile 

339 

340 if self.dim != 3: 

341 raise ValueError('Automatic calculation of mu only possible for ' 

342 'three-dimensional preconditioners. Try setting ' 

343 'mu manually instead.') 

344 

345 if self.r_NN is None: 

346 self.r_NN = estimate_nearest_neighbour_distance(atoms, 

347 self.neighbor_list) 

348 

349 # deformation matrix, default is diagonal 

350 if H is None: 

351 H = 1e-2 * self.r_NN * np.eye(3) 

352 

353 # compute perturbation 

354 p = atoms.get_positions() 

355 

356 if self.estimate_mu_eigmode: 

357 self.mu = 1.0 

358 self.mu_c = 1.0 

359 c_stab = self.c_stab 

360 self.c_stab = 0.0 

361 

362 if isinstance(atoms, Filter): 

363 n = len(atoms.atoms) 

364 else: 

365 n = len(atoms) 

366 self._make_sparse_precon(atoms, initial_assembly=True) 

367 self.P = self.P[:3 * n, :3 * n] 

368 eigvals, eigvecs = sparse.linalg.eigsh(self.P, k=4, which='SM') 

369 

370 logfile.write('estimate_mu(): lowest 4 eigvals = %f %f %f %f\n' % 

371 (eigvals[0], eigvals[1], eigvals[2], eigvals[3])) 

372 # check eigenvalues 

373 if any(eigvals[0:3] > 1e-6): 

374 raise ValueError('First 3 eigenvalues of preconditioner matrix' 

375 'do not correspond to translational modes.') 

376 elif eigvals[3] < 1e-6: 

377 raise ValueError('Fourth smallest eigenvalue of ' 

378 'preconditioner matrix ' 

379 'is too small, increase r_cut.') 

380 

381 x = np.zeros(n) 

382 for i in range(n): 

383 x[i] = eigvecs[:, 3][3 * i] 

384 x = x / np.linalg.norm(x) 

385 if x[0] < 0: 

386 x = -x 

387 

388 v = np.zeros(3 * len(atoms)) 

389 for i in range(n): 

390 v[3 * i] = x[i] 

391 v[3 * i + 1] = x[i] 

392 v[3 * i + 2] = x[i] 

393 v = v / np.linalg.norm(v) 

394 v = v.reshape((-1, 3)) 

395 

396 self.c_stab = c_stab 

397 else: 

398 Lx, Ly, Lz = (p[:, i].max() - p[:, i].min() for i in range(3)) 

399 logfile.write('estimate_mu(): Lx=%.1f Ly=%.1f Lz=%.1f\n' % 

400 (Lx, Ly, Lz)) 

401 

402 x, y, z = p.T 

403 # sine_vr = [np.sin(x/Lx), np.sin(y/Ly), np.sin(z/Lz)], but we need 

404 # to take into account the possibility that one of Lx/Ly/Lz is 

405 # zero. 

406 sine_vr = [x, y, z] 

407 

408 for i, L in enumerate([Lx, Ly, Lz]): 

409 if L == 0: 

410 warnings.warn( 

411 'Cell length L[%d] == 0. Setting H[%d,%d] = 0.' % 

412 (i, i, i)) 

413 H[i, i] = 0.0 

414 else: 

415 sine_vr[i] = np.sin(sine_vr[i] / L) 

416 

417 v = np.dot(H, sine_vr).T 

418 

419 natoms = len(atoms) 

420 if isinstance(atoms, Filter): 

421 natoms = len(atoms.atoms) 

422 eps = H / self.r_NN 

423 v[natoms:, :] = eps 

424 

425 v1 = v.reshape(-1) 

426 

427 # compute LHS 

428 dE_p = -atoms.get_forces().reshape(-1) 

429 atoms_v = atoms.copy() 

430 atoms_v.calc = atoms.calc 

431 if isinstance(atoms, Filter): 

432 atoms_v = atoms.__class__(atoms_v) 

433 if hasattr(atoms, 'constant_volume'): 

434 atoms_v.constant_volume = atoms.constant_volume 

435 atoms_v.set_positions(p + v) 

436 dE_p_plus_v = -atoms_v.get_forces().reshape(-1) 

437 

438 # compute left hand side 

439 LHS = (dE_p_plus_v - dE_p) * v1 

440 

441 # assemble P with \mu = 1 

442 self.mu = 1.0 

443 self.mu_c = 1.0 

444 

445 self._make_sparse_precon(atoms, initial_assembly=True) 

446 

447 # compute right hand side 

448 RHS = self.P.dot(v1) * v1 

449 

450 # use partial sums to compute separate mu for positions and cell DoFs 

451 self.mu = longsum(LHS[:3 * natoms]) / longsum(RHS[:3 * natoms]) 

452 if self.mu < 1.0: 

453 logfile.write('estimate_mu(): mu (%.3f) < 1.0, ' 

454 'capping at mu=1.0' % self.mu) 

455 self.mu = 1.0 

456 

457 if isinstance(atoms, Filter): 

458 self.mu_c = longsum(LHS[3 * natoms:]) / longsum(RHS[3 * natoms:]) 

459 if self.mu_c < 1.0: 

460 logfile.write('estimate_mu(): mu_c (%.3f) < 1.0, ' 

461 'capping at mu_c=1.0\n' % self.mu_c) 

462 self.mu_c = 1.0 

463 

464 logfile.write('estimate_mu(): mu=%r, mu_c=%r\n' % 

465 (self.mu, self.mu_c)) 

466 

467 self.P = None # force a rebuild with new mu (there may be fixed atoms) 

468 return (self.mu, self.mu_c) 

469 

470 def asarray(self): 

471 return np.array(self.P.todense()) 

472 

473 def one_dim_to_ndim(self, csc_P, N): 

474 """ 

475 Expand an N x N precon matrix to self.dim*N x self.dim * N 

476 

477 Args: 

478 csc_P (sparse matrix): N x N sparse matrix, in CSC format 

479 """ 

480 start_time = time.time() 

481 if self.dim == 1: 

482 P = csc_P 

483 elif self.array_convention == 'F': 

484 csc_P = csc_P.tocsr() 

485 P = csc_P 

486 for _ in range(self.dim - 1): 

487 P = sparse.block_diag((P, csc_P)).tocsr() 

488 else: 

489 # convert back to triplet and read the arrays 

490 csc_P = csc_P.tocoo() 

491 i = csc_P.row * self.dim 

492 j = csc_P.col * self.dim 

493 z = csc_P.data 

494 

495 # N-dimensionalise, interlaced coordinates 

496 I = np.hstack([i + d for d in range(self.dim)]) 

497 J = np.hstack([j + d for d in range(self.dim)]) 

498 Z = np.hstack([z for _ in range(self.dim)]) 

499 P = sparse.csc_matrix((Z, (I, J)), 

500 shape=(self.dim * N, self.dim * N)) 

501 P = P.tocsr() 

502 self.logfile.write( 

503 f'--- N-dim precon created in {(time.time() - start_time)} s ---\n') 

504 return P 

505 

506 def create_solver(self): 

507 if self.use_pyamg and have_pyamg: 

508 start_time = time.time() 

509 self.ml = create_pyamg_solver(self.P) 

510 self.logfile.write( 

511 f'--- multi grid solver created in {(time.time() - start_time)}' 

512 ' ---\n') 

513 

514 

515class SparseCoeffPrecon(SparsePrecon): 

516 def _make_sparse_precon(self, atoms, initial_assembly=False, 

517 force_stab=False): 

518 """Create a sparse preconditioner matrix based on the passed atoms. 

519 

520 Creates a general-purpose preconditioner for use with optimization 

521 algorithms, based on examining distances between pairs of atoms in the 

522 lattice. The matrix will be stored in the attribute self.P and 

523 returned. Note that this function will use self.mu, whatever it is. 

524 

525 Args: 

526 atoms: the Atoms object used to create the preconditioner. 

527 

528 Returns: 

529 A scipy.sparse.csr_matrix object, representing a d*N by d*N matrix 

530 (where N is the number of atoms, and d is the value of self.dim). 

531 BE AWARE that using numpy.dot() with this object will result in 

532 errors/incorrect results - use the .dot method directly on the 

533 sparse matrix instead. 

534 

535 """ 

536 logfile = self.logfile 

537 logfile.write('creating sparse precon: initial_assembly=%r, ' 

538 'force_stab=%r, apply_positions=%r, apply_cell=%r\n' % 

539 (initial_assembly, force_stab, self.apply_positions, 

540 self.apply_cell)) 

541 

542 N = len(atoms) 

543 diag_i = np.arange(N, dtype=int) 

544 start_time = time.time() 

545 if self.apply_positions: 

546 # compute neighbour list 

547 i, j, rij, fixed_atoms = get_neighbours( 

548 atoms, self.r_cut, 

549 neighbor_list=self.neighbor_list) 

550 logfile.write( 

551 f'--- neighbour list created in {(time.time() - start_time)} s ' 

552 '--- \n') 

553 

554 # compute entries in triplet format: without the constraints 

555 start_time = time.time() 

556 coeff = self.get_coeff(rij) 

557 diag_coeff = np.bincount(i, -coeff, minlength=N).astype(np.float64) 

558 if force_stab or len(fixed_atoms) == 0: 

559 logfile.write('adding stabilisation to precon') 

560 diag_coeff += self.mu * self.c_stab 

561 else: 

562 diag_coeff = np.ones(N) 

563 

564 # precon is mu_c * identity for cell DoF 

565 if isinstance(atoms, Filter): 

566 if self.apply_cell: 

567 diag_coeff[-3:] = self.mu_c 

568 else: 

569 diag_coeff[-3:] = 1.0 

570 logfile.write( 

571 f'--- computed triplet format in {(time.time() - start_time)} s ' 

572 '---\n') 

573 

574 if self.apply_positions and not initial_assembly: 

575 # apply the constraints 

576 start_time = time.time() 

577 mask = np.ones(N) 

578 mask[fixed_atoms] = 0.0 

579 coeff *= mask[i] * mask[j] 

580 diag_coeff[fixed_atoms] = 1.0 

581 logfile.write( 

582 f'--- applied fixed_atoms in {(time.time() - start_time)} s ' 

583 '---\n') 

584 

585 if self.apply_positions: 

586 # remove zeros 

587 start_time = time.time() 

588 inz = np.nonzero(coeff) 

589 i = np.hstack((i[inz], diag_i)) 

590 j = np.hstack((j[inz], diag_i)) 

591 coeff = np.hstack((coeff[inz], diag_coeff)) 

592 logfile.write( 

593 f'--- remove zeros in {(time.time() - start_time)} s ' 

594 '---\n') 

595 else: 

596 i = diag_i 

597 j = diag_i 

598 coeff = diag_coeff 

599 

600 # create an N x N precon matrix in compressed sparse column (CSC) format 

601 start_time = time.time() 

602 csc_P = sparse.csc_matrix((coeff, (i, j)), shape=(N, N)) 

603 logfile.write( 

604 f'--- created CSC matrix in {(time.time() - start_time)} s ' 

605 '---\n') 

606 

607 self.P = self.one_dim_to_ndim(csc_P, N) 

608 self.create_solver() 

609 

610 def make_precon(self, atoms, reinitialize=None): 

611 if self.r_NN is None: 

612 self.r_NN = estimate_nearest_neighbour_distance(atoms, 

613 self.neighbor_list) 

614 

615 if self.r_cut is None: 

616 # This is the first time this function has been called, and no 

617 # cutoff radius has been specified, so calculate it automatically. 

618 self.r_cut = 2.0 * self.r_NN 

619 elif self.r_cut < self.r_NN: 

620 warning = ('WARNING: r_cut (%.2f) < r_NN (%.2f), ' 

621 'increasing to 1.1*r_NN = %.2f' % (self.r_cut, 

622 self.r_NN, 

623 1.1 * self.r_NN)) 

624 warnings.warn(warning) 

625 self.r_cut = 1.1 * self.r_NN 

626 

627 if reinitialize is None: 

628 # The caller has not specified whether or not to recalculate mu, 

629 # so the Precon's setting is used. 

630 reinitialize = self.reinitialize 

631 

632 if self.mu is None: 

633 # Regardless of what the caller has specified, if we don't 

634 # currently have a value of mu, then we need one. 

635 reinitialize = True 

636 

637 if reinitialize: 

638 self.estimate_mu(atoms) 

639 

640 if self.P is not None: 

641 real_atoms = atoms 

642 if isinstance(atoms, Filter): 

643 real_atoms = atoms.atoms 

644 if self.old_positions is None: 

645 self.old_positions = real_atoms.positions 

646 displacement, _ = find_mic(real_atoms.positions - 

647 self.old_positions, 

648 real_atoms.cell, real_atoms.pbc) 

649 self.old_positions = real_atoms.get_positions() 

650 max_abs_displacement = abs(displacement).max() 

651 self.logfile.write('max(abs(displacements)) = %.2f A (%.2f r_NN)' % 

652 (max_abs_displacement, 

653 max_abs_displacement / self.r_NN)) 

654 if max_abs_displacement < 0.5 * self.r_NN: 

655 return 

656 

657 start_time = time.time() 

658 self._make_sparse_precon(atoms, force_stab=self.force_stab) 

659 self.logfile.write( 

660 f'--- Precon created in {(time.time() - start_time)} seconds ' 

661 '--- \n') 

662 

663 @abstractmethod 

664 def get_coeff(self, r): 

665 ... 

666 

667 

668class Pfrommer(Precon): 

669 """ 

670 Use initial guess for inverse Hessian from Pfrommer et al. as a 

671 simple preconditioner 

672 

673 J. Comput. Phys. vol 131 p233-240 (1997) 

674 """ 

675 

676 def __init__(self, bulk_modulus=500 * units.GPa, phonon_frequency=50 * THz, 

677 apply_positions=True, apply_cell=True): 

678 """ 

679 Default bulk modulus is 500 GPa and default phonon frequency is 50 THz 

680 """ 

681 self.bulk_modulus = bulk_modulus 

682 self.phonon_frequency = phonon_frequency 

683 self.apply_positions = apply_positions 

684 self.apply_cell = apply_cell 

685 self.H0 = None 

686 

687 def make_precon(self, atoms, reinitialize=None): 

688 if self.H0 is not None: 

689 # only build H0 on first call 

690 return 

691 

692 variable_cell = False 

693 if isinstance(atoms, Filter): 

694 variable_cell = True 

695 atoms = atoms.atoms 

696 

697 # position DoF 

698 omega = self.phonon_frequency 

699 mass = atoms.get_masses().mean() 

700 block = np.eye(3) / (mass * omega**2) 

701 blocks = [block] * len(atoms) 

702 

703 # cell DoF 

704 if variable_cell: 

705 coeff = 1.0 

706 if self.apply_cell: 

707 coeff = 1.0 / (3 * self.bulk_modulus) 

708 blocks.append(np.diag([coeff] * 9)) 

709 

710 self.H0 = sparse.block_diag(blocks, format='csr') 

711 return 

712 

713 def Pdot(self, x): 

714 return self.H0.solve(x) 

715 

716 def solve(self, x): 

717 y = self.H0.dot(x) 

718 return y 

719 

720 def copy(self): 

721 return Pfrommer(self.bulk_modulus, 

722 self.phonon_frequency, 

723 self.apply_positions, 

724 self.apply_cell) 

725 

726 def asarray(self): 

727 return np.array(self.H0.todense()) 

728 

729 

730class IdentityPrecon(Precon): 

731 """ 

732 Dummy preconditioner which does not modify forces 

733 """ 

734 

735 def make_precon(self, atoms, reinitialize=None): 

736 self.atoms = atoms 

737 

738 def Pdot(self, x): 

739 return x 

740 

741 def solve(self, x): 

742 return x 

743 

744 def copy(self): 

745 return IdentityPrecon() 

746 

747 def asarray(self): 

748 return np.eye(3 * len(self.atoms)) 

749 

750 

751class C1(SparseCoeffPrecon): 

752 """Creates matrix by inserting a constant whenever r_ij is less than r_cut. 

753 """ 

754 

755 def __init__(self, r_cut=None, mu=None, mu_c=None, dim=3, c_stab=0.1, 

756 force_stab=False, 

757 reinitialize=False, array_convention='C', 

758 solver="auto", solve_tol=1e-9, 

759 apply_positions=True, apply_cell=True, logfile=None): 

760 super().__init__(r_cut=r_cut, mu=mu, mu_c=mu_c, 

761 dim=dim, c_stab=c_stab, 

762 force_stab=force_stab, 

763 reinitialize=reinitialize, 

764 array_convention=array_convention, 

765 solver=solver, solve_tol=solve_tol, 

766 apply_positions=apply_positions, 

767 apply_cell=apply_cell, 

768 logfile=logfile) 

769 

770 def get_coeff(self, r): 

771 return -self.mu * np.ones_like(r) 

772 

773 

774class Exp(SparseCoeffPrecon): 

775 """Creates matrix with values decreasing exponentially with distance. 

776 """ 

777 

778 def __init__(self, A=3.0, r_cut=None, r_NN=None, mu=None, mu_c=None, 

779 dim=3, c_stab=0.1, 

780 force_stab=False, reinitialize=False, array_convention='C', 

781 solver="auto", solve_tol=1e-9, 

782 apply_positions=True, apply_cell=True, 

783 estimate_mu_eigmode=False, logfile=None): 

784 """ 

785 Initialise an Exp preconditioner with given parameters. 

786 

787 Args: 

788 r_cut, mu, c_stab, dim, sparse, reinitialize, array_convention: see 

789 precon.__init__() 

790 A: coefficient in exp(-A*r/r_NN). Default is A=3.0. 

791 """ 

792 super().__init__(r_cut=r_cut, r_NN=r_NN, 

793 mu=mu, mu_c=mu_c, dim=dim, c_stab=c_stab, 

794 force_stab=force_stab, 

795 reinitialize=reinitialize, 

796 array_convention=array_convention, 

797 solver=solver, 

798 solve_tol=solve_tol, 

799 apply_positions=apply_positions, 

800 apply_cell=apply_cell, 

801 estimate_mu_eigmode=estimate_mu_eigmode, 

802 logfile=logfile) 

803 

804 self.A = A 

805 

806 def get_coeff(self, r): 

807 return -self.mu * np.exp(-self.A * (r / self.r_NN - 1)) 

808 

809 

810def ij_to_x(i, j): 

811 x = [3 * i, 3 * i + 1, 3 * i + 2, 

812 3 * j, 3 * j + 1, 3 * j + 2] 

813 return x 

814 

815 

816def ijk_to_x(i, j, k): 

817 x = [3 * i, 3 * i + 1, 3 * i + 2, 

818 3 * j, 3 * j + 1, 3 * j + 2, 

819 3 * k, 3 * k + 1, 3 * k + 2] 

820 return x 

821 

822 

823def ijkl_to_x(i, j, k, l): 

824 x = [3 * i, 3 * i + 1, 3 * i + 2, 

825 3 * j, 3 * j + 1, 3 * j + 2, 

826 3 * k, 3 * k + 1, 3 * k + 2, 

827 3 * l, 3 * l + 1, 3 * l + 2] 

828 return x 

829 

830 

831def apply_fixed(atoms, P): 

832 fixed_atoms = [] 

833 for constraint in atoms.constraints: 

834 if isinstance(constraint, FixAtoms): 

835 fixed_atoms.extend(list(constraint.index)) 

836 else: 

837 raise TypeError( 

838 'only FixAtoms constraints are supported by Precon class') 

839 if len(fixed_atoms) != 0: 

840 P = P.tolil() 

841 for i in fixed_atoms: 

842 P[i, :] = 0.0 

843 P[:, i] = 0.0 

844 P[i, i] = 1.0 

845 return P 

846 

847 

848class FF(SparsePrecon): 

849 """Creates matrix using morse/bond/angle/dihedral force field parameters. 

850 """ 

851 

852 def __init__(self, dim=3, c_stab=0.1, force_stab=False, 

853 array_convention='C', solver="auto", solve_tol=1e-9, 

854 apply_positions=True, apply_cell=True, 

855 hessian='spectral', morses=None, bonds=None, angles=None, 

856 dihedrals=None, logfile=None): 

857 """Initialise an FF preconditioner with given parameters. 

858 

859 Args: 

860 dim, c_stab, force_stab, array_convention, use_pyamg, solve_tol: 

861 see SparsePrecon.__init__() 

862 morses: Morse instance 

863 bonds: Bond instance 

864 angles: Angle instance 

865 dihedrals: Dihedral instance 

866 """ 

867 

868 if (morses is None and bonds is None and angles is None and 

869 dihedrals is None): 

870 raise ImportError( 

871 'At least one of morses, bonds, angles or dihedrals must be ' 

872 'defined!') 

873 

874 super().__init__(dim=dim, c_stab=c_stab, 

875 force_stab=force_stab, 

876 array_convention=array_convention, 

877 solver=solver, 

878 solve_tol=solve_tol, 

879 apply_positions=apply_positions, 

880 apply_cell=apply_cell, logfile=logfile) 

881 

882 self.hessian = hessian 

883 self.morses = morses 

884 self.bonds = bonds 

885 self.angles = angles 

886 self.dihedrals = dihedrals 

887 

888 def make_precon(self, atoms, reinitialize=None): 

889 start_time = time.time() 

890 self._make_sparse_precon(atoms, force_stab=self.force_stab) 

891 self.logfile.write( 

892 f'--- Precon created in {(time.time() - start_time)} seconds ' 

893 '---\n') 

894 

895 def add_morse(self, morse, atoms, row, col, data, conn=None): 

896 if self.hessian == 'reduced': 

897 i, j, Hx = ff.get_morse_potential_reduced_hessian( 

898 atoms, morse) 

899 elif self.hessian == 'spectral': 

900 i, j, Hx = ff.get_morse_potential_hessian( 

901 atoms, morse, spectral=True) 

902 else: 

903 raise NotImplementedError('Not implemented hessian') 

904 x = ij_to_x(i, j) 

905 row.extend(np.repeat(x, 6)) 

906 col.extend(np.tile(x, 6)) 

907 data.extend(Hx.flatten()) 

908 if conn is not None: 

909 conn[i, j] = True 

910 conn[j, i] = True 

911 

912 def add_bond(self, bond, atoms, row, col, data, conn=None): 

913 if self.hessian == 'reduced': 

914 i, j, Hx = ff.get_bond_potential_reduced_hessian( 

915 atoms, bond, self.morses) 

916 elif self.hessian == 'spectral': 

917 i, j, Hx = ff.get_bond_potential_hessian( 

918 atoms, bond, self.morses, spectral=True) 

919 else: 

920 raise NotImplementedError('Not implemented hessian') 

921 x = ij_to_x(i, j) 

922 row.extend(np.repeat(x, 6)) 

923 col.extend(np.tile(x, 6)) 

924 data.extend(Hx.flatten()) 

925 if conn is not None: 

926 conn[i, j] = True 

927 conn[j, i] = True 

928 

929 def add_angle(self, angle, atoms, row, col, data, conn=None): 

930 if self.hessian == 'reduced': 

931 i, j, k, Hx = ff.get_angle_potential_reduced_hessian( 

932 atoms, angle, self.morses) 

933 elif self.hessian == 'spectral': 

934 i, j, k, Hx = ff.get_angle_potential_hessian( 

935 atoms, angle, self.morses, spectral=True) 

936 else: 

937 raise NotImplementedError('Not implemented hessian') 

938 x = ijk_to_x(i, j, k) 

939 row.extend(np.repeat(x, 9)) 

940 col.extend(np.tile(x, 9)) 

941 data.extend(Hx.flatten()) 

942 if conn is not None: 

943 conn[i, j] = conn[i, k] = conn[j, k] = True 

944 conn[j, i] = conn[k, i] = conn[k, j] = True 

945 

946 def add_dihedral(self, dihedral, atoms, row, col, data, conn=None): 

947 if self.hessian == 'reduced': 

948 i, j, k, l, Hx = \ 

949 ff.get_dihedral_potential_reduced_hessian( 

950 atoms, dihedral, self.morses) 

951 elif self.hessian == 'spectral': 

952 i, j, k, l, Hx = ff.get_dihedral_potential_hessian( 

953 atoms, dihedral, self.morses, spectral=True) 

954 else: 

955 raise NotImplementedError('Not implemented hessian') 

956 x = ijkl_to_x(i, j, k, l) 

957 row.extend(np.repeat(x, 12)) 

958 col.extend(np.tile(x, 12)) 

959 data.extend(Hx.flatten()) 

960 if conn is not None: 

961 conn[i, j] = conn[i, k] = conn[i, l] = conn[ 

962 j, k] = conn[j, l] = conn[k, l] = True 

963 conn[j, i] = conn[k, i] = conn[l, i] = conn[ 

964 k, j] = conn[l, j] = conn[l, k] = True 

965 

966 def _make_sparse_precon(self, atoms, initial_assembly=False, 

967 force_stab=False): 

968 N = len(atoms) 

969 

970 row = [] 

971 col = [] 

972 data = [] 

973 

974 if self.morses is not None: 

975 for morse in self.morses: 

976 self.add_morse(morse, atoms, row, col, data) 

977 

978 if self.bonds is not None: 

979 for bond in self.bonds: 

980 self.add_bond(bond, atoms, row, col, data) 

981 

982 if self.angles is not None: 

983 for angle in self.angles: 

984 self.add_angle(angle, atoms, row, col, data) 

985 

986 if self.dihedrals is not None: 

987 for dihedral in self.dihedrals: 

988 self.add_dihedral(dihedral, atoms, row, col, data) 

989 

990 # add the diagonal 

991 row.extend(range(self.dim * N)) 

992 col.extend(range(self.dim * N)) 

993 data.extend([self.c_stab] * self.dim * N) 

994 

995 # create the matrix 

996 start_time = time.time() 

997 self.P = sparse.csc_matrix( 

998 (data, (row, col)), shape=(self.dim * N, self.dim * N)) 

999 self.logfile.write( 

1000 f'--- created CSC matrix in {(time.time() - start_time)} s ---\n') 

1001 

1002 self.P = apply_fixed(atoms, self.P) 

1003 self.P = self.P.tocsr() 

1004 self.logfile.write( 

1005 f'--- N-dim precon created in {(time.time() - start_time)} s ---\n') 

1006 self.create_solver() 

1007 

1008 

1009class Exp_FF(Exp, FF): 

1010 """Creates matrix with values decreasing exponentially with distance. 

1011 """ 

1012 

1013 def __init__(self, A=3.0, r_cut=None, r_NN=None, mu=None, mu_c=None, 

1014 dim=3, c_stab=0.1, 

1015 force_stab=False, reinitialize=False, array_convention='C', 

1016 solver="auto", solve_tol=1e-9, 

1017 apply_positions=True, apply_cell=True, 

1018 estimate_mu_eigmode=False, 

1019 hessian='spectral', morses=None, bonds=None, angles=None, 

1020 dihedrals=None, logfile=None): 

1021 """Initialise an Exp+FF preconditioner with given parameters. 

1022 

1023 Args: 

1024 r_cut, mu, c_stab, dim, reinitialize, array_convention: see 

1025 precon.__init__() 

1026 A: coefficient in exp(-A*r/r_NN). Default is A=3.0. 

1027 """ 

1028 if (morses is None and bonds is None and angles is None and 

1029 dihedrals is None): 

1030 raise ImportError( 

1031 'At least one of morses, bonds, angles or dihedrals must ' 

1032 'be defined!') 

1033 

1034 SparsePrecon.__init__(self, r_cut=r_cut, r_NN=r_NN, 

1035 mu=mu, mu_c=mu_c, dim=dim, c_stab=c_stab, 

1036 force_stab=force_stab, 

1037 reinitialize=reinitialize, 

1038 array_convention=array_convention, 

1039 solver=solver, 

1040 solve_tol=solve_tol, 

1041 apply_positions=apply_positions, 

1042 apply_cell=apply_cell, 

1043 estimate_mu_eigmode=estimate_mu_eigmode, 

1044 logfile=logfile) 

1045 

1046 self.A = A 

1047 self.hessian = hessian 

1048 self.morses = morses 

1049 self.bonds = bonds 

1050 self.angles = angles 

1051 self.dihedrals = dihedrals 

1052 

1053 def make_precon(self, atoms, reinitialize=None): 

1054 if self.r_NN is None: 

1055 self.r_NN = estimate_nearest_neighbour_distance(atoms, 

1056 self.neighbor_list) 

1057 

1058 if self.r_cut is None: 

1059 # This is the first time this function has been called, and no 

1060 # cutoff radius has been specified, so calculate it automatically. 

1061 self.r_cut = 2.0 * self.r_NN 

1062 elif self.r_cut < self.r_NN: 

1063 warning = ('WARNING: r_cut (%.2f) < r_NN (%.2f), ' 

1064 'increasing to 1.1*r_NN = %.2f' % (self.r_cut, 

1065 self.r_NN, 

1066 1.1 * self.r_NN)) 

1067 warnings.warn(warning) 

1068 self.r_cut = 1.1 * self.r_NN 

1069 

1070 if reinitialize is None: 

1071 # The caller has not specified whether or not to recalculate mu, 

1072 # so the Precon's setting is used. 

1073 reinitialize = self.reinitialize 

1074 

1075 if self.mu is None: 

1076 # Regardless of what the caller has specified, if we don't 

1077 # currently have a value of mu, then we need one. 

1078 reinitialize = True 

1079 

1080 if reinitialize: 

1081 self.estimate_mu(atoms) 

1082 

1083 if self.P is not None: 

1084 real_atoms = atoms 

1085 if isinstance(atoms, Filter): 

1086 real_atoms = atoms.atoms 

1087 if self.old_positions is None: 

1088 self.old_positions = real_atoms.positions 

1089 displacement, _ = find_mic(real_atoms.positions - 

1090 self.old_positions, 

1091 real_atoms.cell, real_atoms.pbc) 

1092 self.old_positions = real_atoms.get_positions() 

1093 max_abs_displacement = abs(displacement).max() 

1094 self.logfile.write('max(abs(displacements)) = %.2f A (%.2f r_NN)' % 

1095 (max_abs_displacement, 

1096 max_abs_displacement / self.r_NN)) 

1097 if max_abs_displacement < 0.5 * self.r_NN: 

1098 return 

1099 

1100 # Create the preconditioner: 

1101 start_time = time.time() 

1102 self._make_sparse_precon(atoms, force_stab=self.force_stab) 

1103 self.logfile.write( 

1104 f'--- Precon created in {(time.time() - start_time)} seconds ---\n') 

1105 

1106 def _make_sparse_precon(self, atoms, initial_assembly=False, 

1107 force_stab=False): 

1108 """Create a sparse preconditioner matrix based on the passed atoms. 

1109 

1110 Args: 

1111 atoms: the Atoms object used to create the preconditioner. 

1112 

1113 Returns: 

1114 A scipy.sparse.csr_matrix object, representing a d*N by d*N matrix 

1115 (where N is the number of atoms, and d is the value of self.dim). 

1116 BE AWARE that using numpy.dot() with this object will result in 

1117 errors/incorrect results - use the .dot method directly on the 

1118 sparse matrix instead. 

1119 

1120 """ 

1121 self.logfile.write('creating sparse precon: initial_assembly=%r, ' 

1122 'force_stab=%r, apply_positions=%r, ' 

1123 'apply_cell=%r\n' % 

1124 (initial_assembly, force_stab, 

1125 self.apply_positions, self.apply_cell)) 

1126 

1127 N = len(atoms) 

1128 start_time = time.time() 

1129 if self.apply_positions: 

1130 # compute neighbour list 

1131 i_list, j_list, rij_list, _fixed_atoms = get_neighbours( 

1132 atoms, self.r_cut, self.neighbor_list) 

1133 self.logfile.write( 

1134 f'--- neighbour list created in {(time.time() - start_time)} s ' 

1135 '---\n') 

1136 

1137 row = [] 

1138 col = [] 

1139 data = [] 

1140 

1141 # precon is mu_c*identity for cell DoF 

1142 start_time = time.time() 

1143 if isinstance(atoms, Filter): 

1144 i = N - 3 

1145 j = N - 2 

1146 k = N - 1 

1147 x = ijk_to_x(i, j, k) 

1148 row.extend(x) 

1149 col.extend(x) 

1150 if self.apply_cell: 

1151 data.extend(np.repeat(self.mu_c, 9)) 

1152 else: 

1153 data.extend(np.repeat(self.mu_c, 9)) 

1154 self.logfile.write( 

1155 f'--- computed triplet format in {(time.time() - start_time)} s ' 

1156 '---\n') 

1157 

1158 conn = sparse.lil_matrix((N, N), dtype=bool) 

1159 

1160 if self.apply_positions and not initial_assembly: 

1161 if self.morses is not None: 

1162 for morse in self.morses: 

1163 self.add_morse(morse, atoms, row, col, data, conn) 

1164 

1165 if self.bonds is not None: 

1166 for bond in self.bonds: 

1167 self.add_bond(bond, atoms, row, col, data, conn) 

1168 

1169 if self.angles is not None: 

1170 for angle in self.angles: 

1171 self.add_angle(angle, atoms, row, col, data, conn) 

1172 

1173 if self.dihedrals is not None: 

1174 for dihedral in self.dihedrals: 

1175 self.add_dihedral(dihedral, atoms, row, col, data, conn) 

1176 

1177 if self.apply_positions: 

1178 for i, j, rij in zip(i_list, j_list, rij_list): 

1179 if not conn[i, j]: 

1180 coeff = self.get_coeff(rij) 

1181 x = ij_to_x(i, j) 

1182 row.extend(x) 

1183 col.extend(x) 

1184 data.extend(3 * [-coeff] + 3 * [coeff]) 

1185 

1186 row.extend(range(self.dim * N)) 

1187 col.extend(range(self.dim * N)) 

1188 if initial_assembly: 

1189 data.extend([self.mu * self.c_stab] * self.dim * N) 

1190 else: 

1191 data.extend([self.c_stab] * self.dim * N) 

1192 

1193 # create the matrix 

1194 start_time = time.time() 

1195 self.P = sparse.csc_matrix( 

1196 (data, (row, col)), shape=(self.dim * N, self.dim * N)) 

1197 self.logfile.write( 

1198 f'--- created CSC matrix in {(time.time() - start_time)} s ---\n') 

1199 

1200 if not initial_assembly: 

1201 self.P = apply_fixed(atoms, self.P) 

1202 

1203 self.P = self.P.tocsr() 

1204 self.create_solver() 

1205 

1206 

1207def make_precon(precon, atoms=None, **kwargs): 

1208 """ 

1209 Construct preconditioner from a string and optionally build for Atoms 

1210 

1211 Parameters 

1212 ---------- 

1213 precon - one of 'C1', 'Exp', 'Pfrommer', 'FF', 'Exp_FF', 'ID', None 

1214 or an instance of a subclass of `ase.optimize.precon.Precon` 

1215 

1216 atoms - ase.atoms.Atoms instance, optional 

1217 If present, build apreconditioner for this Atoms object 

1218 

1219 **kwargs - additional keyword arguments to pass to Precon constructor 

1220 

1221 Returns 

1222 ------- 

1223 precon - instance of relevant subclass of `ase.optimize.precon.Precon` 

1224 """ 

1225 lookup = { 

1226 'C1': C1, 

1227 'Exp': Exp, 

1228 'Pfrommer': Pfrommer, 

1229 'FF': FF, 

1230 'Exp_FF': Exp_FF, 

1231 'ID': IdentityPrecon, 

1232 'IdentityPrecon': IdentityPrecon, 

1233 None: IdentityPrecon 

1234 } 

1235 if isinstance(precon, str) or precon is None: 

1236 cls = lookup[precon] 

1237 precon = cls(**kwargs) 

1238 if atoms is not None: 

1239 precon.make_precon(atoms) 

1240 return precon 

1241 

1242 

1243class SplineFit: 

1244 """ 

1245 Fit a cubic spline fit to images 

1246 """ 

1247 

1248 def __init__(self, s, x): 

1249 self._s = s 

1250 self._x_data = x 

1251 self._x = CubicSpline(self._s, x, bc_type='not-a-knot') 

1252 self._dx_ds = self._x.derivative() 

1253 self._d2x_ds2 = self._x.derivative(2) 

1254 

1255 @property 

1256 def s(self): 

1257 return self._s 

1258 

1259 @property 

1260 def x_data(self): 

1261 return self._x_data 

1262 

1263 @property 

1264 def x(self): 

1265 return self._x 

1266 

1267 @property 

1268 def dx_ds(self): 

1269 return self._dx_ds 

1270 

1271 @property 

1272 def d2x_ds2(self): 

1273 return self._d2x_ds2 

1274 

1275 

1276class PreconImages: 

1277 def __init__(self, precon, images, **kwargs): 

1278 """ 

1279 Wrapper for a list of Precon objects and associated images 

1280 

1281 This is used when preconditioning a NEB object. Equation references 

1282 refer to Paper IV in the :class:`ase.mep.NEB` documentation, i.e. 

1283 

1284 S. Makri, C. Ortner and J. R. Kermode, J. Chem. Phys. 

1285 150, 094109 (2019) 

1286 https://dx.doi.org/10.1063/1.5064465 

1287 

1288 Args: 

1289 precon (str or list): preconditioner(s) to use 

1290 images (list of Atoms): Atoms objects that define the state 

1291 

1292 """ 

1293 self.images = images 

1294 self._spline = None 

1295 

1296 if isinstance(precon, list): 

1297 if len(precon) != len(images): 

1298 raise ValueError(f'length mismatch: len(precon)={len(precon)} ' 

1299 f'!= len(images)={len(images)}') 

1300 self.precon = precon 

1301 return 

1302 P0 = make_precon(precon, images[0], **kwargs) 

1303 self.precon = [P0] 

1304 for image in images[1:]: 

1305 P = P0.copy() 

1306 P.make_precon(image) 

1307 self.precon.append(P) 

1308 

1309 def __len__(self): 

1310 return len(self.precon) 

1311 

1312 def __iter__(self): 

1313 return iter(self.precon) 

1314 

1315 def __getitem__(self, index): 

1316 return self.precon[index] 

1317 

1318 def apply(self, all_forces, index=None): 

1319 """Apply preconditioners to stored images 

1320 

1321 Args: 

1322 all_forces (array): forces on images, shape (nimages, natoms, 3) 

1323 index (slice, optional): Which images to include. Defaults to all. 

1324 

1325 Returns: 

1326 precon_forces: array of preconditioned forces 

1327 """ 

1328 if index is None: 

1329 index = slice(None) 

1330 precon_forces = [] 

1331 for precon, image, forces in zip(self.precon[index], 

1332 self.images[index], 

1333 all_forces): 

1334 f_vec = forces.reshape(-1) 

1335 pf_vec, _ = precon.apply(f_vec, image) 

1336 precon_forces.append(pf_vec.reshape(-1, 3)) 

1337 

1338 return np.array(precon_forces) 

1339 

1340 def average_norm(self, i, j, dx): 

1341 """Average norm between images i and j 

1342 

1343 Args: 

1344 i (int): left image 

1345 j (int): right image 

1346 dx (array): vector 

1347 

1348 Returns: 

1349 norm: norm of vector wrt average of precons at i and j 

1350 """ 

1351 return np.sqrt(0.5 * (self.precon[i].dot(dx, dx) + 

1352 self.precon[j].dot(dx, dx))) 

1353 

1354 def get_tangent(self, i): 

1355 """Normalised tangent vector at image i 

1356 

1357 Args: 

1358 i (int): image of interest 

1359 

1360 Returns: 

1361 tangent: tangent vector, normalised with appropriate precon norm 

1362 """ 

1363 tangent = self.spline.dx_ds(self.spline.s[i]) 

1364 tangent /= self.precon[i].norm(tangent) 

1365 return tangent.reshape(-1, 3) 

1366 

1367 def get_residual(self, i, imgforce): 

1368 # residuals computed according to eq. 11 in the paper 

1369 P_dot_imgforce = self.precon[i].Pdot(imgforce.reshape(-1)) 

1370 return np.linalg.norm(P_dot_imgforce, np.inf) 

1371 

1372 def get_spring_force(self, i, k1, k2, tangent): 

1373 """Spring force on image 

1374 

1375 Args: 

1376 i (int): image of interest 

1377 k1 (float): spring constant for left spring 

1378 k2 (float): spring constant for right spring 

1379 tangent (array): tangent vector, shape (natoms, 3) 

1380 

1381 Returns: 

1382 eta: NEB spring forces, shape (natoms, 3) 

1383 """ 

1384 # Definition following Eq. 9 in Paper IV 

1385 nimages = len(self.images) 

1386 k = 0.5 * (k1 + k2) / (nimages ** 2) 

1387 curvature = self.spline.d2x_ds2(self.spline.s[i]).reshape(-1, 3) 

1388 # complete Eq. 9 by including the spring force 

1389 eta = k * self.precon[i].vdot(curvature, tangent) * tangent 

1390 return eta 

1391 

1392 def get_coordinates(self, positions=None): 

1393 """Compute displacements wrt appropriate precon metric for each image 

1394 

1395 Args: 

1396 positions (list or array, optional) - images positions. 

1397 Shape either (nimages * natoms, 3) or ((nimages-2)*natoms, 3) 

1398 

1399 Returns: 

1400 s : array shape (nimages,), reaction coordinates, in range [0, 1] 

1401 x : array shape (nimages, 3 * natoms), flat displacement vectors 

1402 """ 

1403 nimages = len(self.precon) 

1404 natoms = len(self.images[0]) 

1405 d_P = np.zeros(nimages) 

1406 x = np.zeros((nimages, 3 * natoms)) # flattened positions 

1407 if positions is None: 

1408 positions = [image.positions for image in self.images] 

1409 elif isinstance(positions, np.ndarray) and len(positions.shape) == 2: 

1410 positions = positions.reshape(-1, natoms, 3) 

1411 positions = [positions[i, :, :] for i in range(len(positions))] 

1412 if len(positions) == len(self.images) - 2: 

1413 # prepend and append the non-moving images 

1414 positions = ([self.images[0].positions] + positions + 

1415 [self.images[-1].positions]) 

1416 assert len(positions) == len(self.images) 

1417 

1418 x[0, :] = positions[0].reshape(-1) 

1419 for i in range(1, nimages): 

1420 x[i, :] = positions[i].reshape(-1) 

1421 dx, _ = find_mic(positions[i] - positions[i - 1], 

1422 self.images[i - 1].cell, 

1423 self.images[i - 1].pbc) 

1424 dx = dx.reshape(-1) 

1425 d_P[i] = self.average_norm(i, i - 1, dx) 

1426 

1427 s = d_P.cumsum() / d_P.sum() # Eq. A1 in paper IV 

1428 return s, x 

1429 

1430 def spline_fit(self, positions=None): 

1431 """Fit 3 * natoms cubic splines as a function of reaction coordinate 

1432 

1433 Returns: 

1434 fit : :class:`ase.optimize.precon.SplineFit` object 

1435 """ 

1436 s, x = self.get_coordinates(positions) 

1437 return SplineFit(s, x) 

1438 

1439 @property 

1440 def spline(self): 

1441 s, x = self.get_coordinates() 

1442 if self._spline and (np.abs(s - self._old_s).max() < 1e-6 and 

1443 np.abs(x - self._old_x).max() < 1e-6): 

1444 return self._spline 

1445 

1446 self._spline = self.spline_fit() 

1447 self._old_s = s 

1448 self._old_x = x 

1449 return self._spline