Coverage for ase / mep / dimer.py: 71.79%

585 statements  

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

1# fmt: off 

2 

3"""Minimum mode follower for finding saddle points in an unbiased way. 

4 

5There is, currently, only one implemented method: The Dimer method. 

6""" 

7 

8import sys 

9import time 

10import warnings 

11from math import atan, cos, degrees, pi, sin, sqrt, tan 

12from typing import Any 

13 

14import numpy as np 

15 

16from ase.calculators.singlepoint import SinglePointCalculator 

17from ase.optimize.optimize import OptimizableAtoms, Optimizer 

18from ase.parallel import world 

19from ase.utils import IOContext 

20 

21# Handy vector methods 

22norm = np.linalg.norm 

23 

24 

25class DimerOptimizable(OptimizableAtoms): 

26 def __init__(self, dimeratoms): 

27 self.dimeratoms = dimeratoms 

28 super().__init__(dimeratoms) 

29 

30 def converged(self, forces, fmax): 

31 forces_converged = super().converged(forces, fmax) 

32 return forces_converged and self.dimeratoms.get_curvature() < 0.0 

33 

34 

35def normalize(vector): 

36 """Create a unit vector along *vector*""" 

37 return vector / norm(vector) 

38 

39 

40def parallel_vector(vector, base): 

41 """Extract the components of *vector* that are parallel to *base*""" 

42 return np.vdot(vector, base) * base 

43 

44 

45def perpendicular_vector(vector, base): 

46 """Remove the components of *vector* that are parallel to *base*""" 

47 return vector - parallel_vector(vector, base) 

48 

49 

50def rotate_vectors(v1i, v2i, angle): 

51 """Rotate vectors *v1i* and *v2i* by *angle*""" 

52 cAng = cos(angle) 

53 sAng = sin(angle) 

54 v1o = v1i * cAng + v2i * sAng 

55 v2o = v2i * cAng - v1i * sAng 

56 # Ensure the length of the input and output vectors is equal 

57 return normalize(v1o) * norm(v1i), normalize(v2o) * norm(v2i) 

58 

59 

60class DimerEigenmodeSearch: 

61 """An implementation of the Dimer's minimum eigenvalue mode search. 

62 

63 This class implements the rotational part of the dimer saddle point 

64 searching method. 

65 

66 Parameters 

67 ---------- 

68 

69 atoms: MinModeAtoms object 

70 MinModeAtoms is an extension to the Atoms object, which includes 

71 information about the lowest eigenvalue mode. 

72 control: DimerControl object 

73 Contains the parameters necessary for the eigenmode search. 

74 If no control object is supplied a default DimerControl 

75 will be created and used. 

76 basis: list of xyz-values 

77 Eigenmode. Must be an ndarray of shape (n, 3). 

78 It is possible to constrain the eigenmodes to be orthogonal 

79 to this given eigenmode. 

80 

81 Notes 

82 ----- 

83 

84 The code is inspired, with permission, by code written by the Henkelman 

85 group, which can be found at http://theory.cm.utexas.edu/vtsttools/code/ 

86 

87 References 

88 ---------- 

89 

90 * Henkelman and Jonsson, JCP 111, 7010 (1999) 

91 * Olsen, Kroes, Henkelman, Arnaldsson, and Jonsson, JCP 121, 

92 9776 (2004). 

93 * Heyden, Bell, and Keil, JCP 123, 224101 (2005). 

94 * Kastner and Sherwood, JCP 128, 014106 (2008). 

95 

96 """ 

97 

98 def __init__(self, dimeratoms, control=None, eigenmode=None, basis=None, 

99 **kwargs): 

100 if hasattr(dimeratoms, 'get_eigenmode'): 

101 self.dimeratoms = dimeratoms 

102 else: 

103 e = 'The atoms object must be a MinModeAtoms object' 

104 raise TypeError(e) 

105 self.basis = basis 

106 

107 if eigenmode is None: 

108 self.eigenmode = self.dimeratoms.get_eigenmode() 

109 else: 

110 self.eigenmode = eigenmode 

111 

112 if control is None: 

113 self.control = DimerControl(**kwargs) 

114 w = 'Missing control object in ' + self.__class__.__name__ + \ 

115 '. Using default: DimerControl()' 

116 warnings.warn(w, UserWarning) 

117 if self.control.logfile is not None: 

118 self.control.logfile.write('DIM:WARN: ' + w + '\n') 

119 self.control.logfile.flush() 

120 else: 

121 self.control = control 

122 # kwargs must be empty if a control object is supplied 

123 for key in kwargs: 

124 e = f'__init__() got an unexpected keyword argument \'{(key)}\'' 

125 raise TypeError(e) 

126 

127 self.dR = self.control.get_parameter('dimer_separation') 

128 self.logfile = self.control.get_logfile() 

129 

130 def converge_to_eigenmode(self): 

131 """Perform an eigenmode search.""" 

132 self.set_up_for_eigenmode_search() 

133 stoprot = False 

134 

135 # Load the relevant parameters from control 

136 f_rot_min = self.control.get_parameter('f_rot_min') 

137 f_rot_max = self.control.get_parameter('f_rot_max') 

138 trial_angle = self.control.get_parameter('trial_angle') 

139 max_num_rot = self.control.get_parameter('max_num_rot') 

140 extrapolate = self.control.get_parameter('extrapolate_forces') 

141 

142 while not stoprot: 

143 if self.forces1E is None: 

144 self.update_virtual_forces() 

145 else: 

146 self.update_virtual_forces(extrapolated_forces=True) 

147 self.forces1A = self.forces1 

148 self.update_curvature() 

149 f_rot_A = self.get_rotational_force() 

150 

151 # Pre rotation stop criteria 

152 if norm(f_rot_A) <= f_rot_min: 

153 self.log(f_rot_A, None) 

154 stoprot = True 

155 else: 

156 n_A = self.eigenmode 

157 rot_unit_A = normalize(f_rot_A) 

158 

159 # Get the curvature and its derivative 

160 c0 = self.get_curvature() 

161 c0d = np.vdot((self.forces2 - self.forces1), rot_unit_A) / \ 

162 self.dR 

163 

164 # Trial rotation (no need to store the curvature) 

165 # NYI variable trial angles from [3] 

166 n_B, rot_unit_B = rotate_vectors(n_A, rot_unit_A, trial_angle) 

167 self.eigenmode = n_B 

168 self.update_virtual_forces() 

169 self.forces1B = self.forces1 

170 

171 # Get the curvature's derivative 

172 c1d = np.vdot((self.forces2 - self.forces1), rot_unit_B) / \ 

173 self.dR 

174 

175 # Calculate the Fourier coefficients 

176 a1 = c0d * cos(2 * trial_angle) - c1d / \ 

177 (2 * sin(2 * trial_angle)) 

178 b1 = 0.5 * c0d 

179 a0 = 2 * (c0 - a1) 

180 

181 # Estimate the rotational angle 

182 rotangle = atan(b1 / a1) / 2.0 

183 

184 # Make sure that you didn't find a maximum 

185 cmin = a0 / 2.0 + a1 * cos(2 * rotangle) + \ 

186 b1 * sin(2 * rotangle) 

187 if c0 < cmin: 

188 rotangle += pi / 2.0 

189 

190 # Rotate into the (hopefully) lowest eigenmode 

191 # NYI Conjugate gradient rotation 

192 n_min, _dummy = rotate_vectors(n_A, rot_unit_A, rotangle) 

193 self.update_eigenmode(n_min) 

194 

195 # Store the curvature estimate instead of the old curvature 

196 self.update_curvature(cmin) 

197 

198 self.log(f_rot_A, rotangle) 

199 

200 # Force extrapolation scheme from [4] 

201 if extrapolate: 

202 self.forces1E = sin(trial_angle - rotangle) / \ 

203 sin(trial_angle) * self.forces1A + sin(rotangle) / \ 

204 sin(trial_angle) * self.forces1B + \ 

205 (1 - cos(rotangle) - sin(rotangle) * 

206 tan(trial_angle / 2.0)) * self.forces0 

207 else: 

208 self.forces1E = None 

209 

210 # Post rotation stop criteria 

211 if not stoprot: 

212 if self.control.get_counter('rotcount') >= max_num_rot: 

213 stoprot = True 

214 elif norm(f_rot_A) <= f_rot_max: 

215 stoprot = True 

216 

217 def log(self, f_rot_A, angle): 

218 """Log each rotational step.""" 

219 # NYI Log for the trial angle 

220 if self.logfile is not None: 

221 if angle: 

222 l = 'DIM:ROT: %7d %9d %9.4f %9.4f %9.4f\n' % \ 

223 (self.control.get_counter('optcount'), 

224 self.control.get_counter('rotcount'), 

225 self.get_curvature(), degrees(angle), norm(f_rot_A)) 

226 else: 

227 l = 'DIM:ROT: %7d %9d %9.4f %9s %9.4f\n' % \ 

228 (self.control.get_counter('optcount'), 

229 self.control.get_counter('rotcount'), 

230 self.get_curvature(), '---------', norm(f_rot_A)) 

231 self.logfile.write(l) 

232 

233 def get_rotational_force(self): 

234 """Calculate the rotational force that acts on the dimer.""" 

235 rot_force = perpendicular_vector((self.forces1 - self.forces2), 

236 self.eigenmode) / (2.0 * self.dR) 

237 if self.basis is not None: 

238 if ( 

239 len(self.basis) == len(self.dimeratoms) 

240 and len(self.basis[0]) == 3 

241 and isinstance(self.basis[0][0], float)): 

242 rot_force = perpendicular_vector(rot_force, self.basis) 

243 else: 

244 for base in self.basis: 

245 rot_force = perpendicular_vector(rot_force, base) 

246 return rot_force 

247 

248 def update_curvature(self, curv=None): 

249 """Update the curvature in the MinModeAtoms object.""" 

250 if curv: 

251 self.curvature = curv 

252 else: 

253 self.curvature = np.vdot((self.forces2 - self.forces1), 

254 self.eigenmode) / (2.0 * self.dR) 

255 

256 def update_eigenmode(self, eigenmode): 

257 """Update the eigenmode in the MinModeAtoms object.""" 

258 self.eigenmode = eigenmode 

259 self.update_virtual_positions() 

260 self.control.increment_counter('rotcount') 

261 

262 def get_eigenmode(self): 

263 """Returns the current eigenmode.""" 

264 return self.eigenmode 

265 

266 def get_curvature(self): 

267 """Returns the curvature along the current eigenmode.""" 

268 return self.curvature 

269 

270 def get_control(self): 

271 """Return the control object.""" 

272 return self.control 

273 

274 def update_center_forces(self): 

275 """Get the forces at the center of the dimer.""" 

276 self.dimeratoms.set_positions(self.pos0) 

277 self.forces0 = self.dimeratoms.get_forces(real=True) 

278 self.energy0 = self.dimeratoms.get_potential_energy() 

279 

280 def update_virtual_forces(self, extrapolated_forces=False): 

281 """Get the forces at the endpoints of the dimer.""" 

282 self.update_virtual_positions() 

283 

284 # Estimate / Calculate the forces at pos1 

285 if extrapolated_forces: 

286 self.forces1 = self.forces1E.copy() 

287 else: 

288 self.forces1 = self.dimeratoms.get_forces(real=True, pos=self.pos1) 

289 

290 # Estimate / Calculate the forces at pos2 

291 if self.control.get_parameter('use_central_forces'): 

292 self.forces2 = 2 * self.forces0 - self.forces1 

293 else: 

294 self.forces2 = self.dimeratoms.get_forces(real=True, pos=self.pos2) 

295 

296 def update_virtual_positions(self): 

297 """Update the end point positions.""" 

298 self.pos1 = self.pos0 + self.eigenmode * self.dR 

299 self.pos2 = self.pos0 - self.eigenmode * self.dR 

300 

301 def set_up_for_eigenmode_search(self): 

302 """Before eigenmode search, prepare for rotation.""" 

303 self.pos0 = self.dimeratoms.get_positions() 

304 self.update_center_forces() 

305 self.update_virtual_positions() 

306 self.control.reset_counter('rotcount') 

307 self.forces1E = None 

308 

309 def set_up_for_optimization_step(self): 

310 """At the end of rotation, prepare for displacement of the dimer.""" 

311 self.dimeratoms.set_positions(self.pos0) 

312 self.forces1E = None 

313 

314 

315class MinModeControl(IOContext): 

316 """A parent class for controlling minimum mode saddle point searches. 

317 

318 Method specific control classes inherit this one. The only thing 

319 inheriting classes need to implement are the log() method and 

320 the *parameters* class variable with default values for ALL 

321 parameters needed by the method in question. 

322 When instantiating control classes default parameter values can 

323 be overwritten. 

324 

325 """ 

326 parameters: dict[str, Any] = {} 

327 

328 def __init__(self, logfile='-', eigenmode_logfile=None, comm=world, 

329 **kwargs): 

330 # Overwrite the defaults with the input parameters given 

331 for key in kwargs: 

332 if key not in self.parameters: 

333 e = (f'Invalid parameter >>{key}<< with value >>' 

334 f'{kwargs[key]!s}<< in {self.__class__.__name__}') 

335 raise ValueError(e) 

336 else: 

337 self.set_parameter(key, kwargs[key], log=False) 

338 

339 self.initialize_logfiles(comm=comm, logfile=logfile, 

340 eigenmode_logfile=eigenmode_logfile) 

341 self.counters = {'forcecalls': 0, 'rotcount': 0, 'optcount': 0} 

342 self.log() 

343 

344 def initialize_logfiles(self, comm, logfile=None, eigenmode_logfile=None): 

345 self.logfile = self.openfile(file=logfile, comm=comm) 

346 self.eigenmode_logfile = self.openfile(file=eigenmode_logfile, 

347 comm=comm) 

348 

349 def log(self, parameter=None): 

350 """Log the parameters of the eigenmode search.""" 

351 

352 def set_parameter(self, parameter, value, log=True): 

353 """Change a parameter's value.""" 

354 if parameter not in self.parameters: 

355 e = f'Invalid parameter >>{parameter}<< with value >>{value!s}<<' 

356 raise ValueError(e) 

357 self.parameters[parameter] = value 

358 if log: 

359 self.log(parameter) 

360 

361 def get_parameter(self, parameter): 

362 """Returns the value of a parameter.""" 

363 if parameter not in self.parameters: 

364 e = f'Invalid parameter >>{(parameter)}<<' 

365 raise ValueError(e) 

366 return self.parameters[parameter] 

367 

368 def get_logfile(self): 

369 """Returns the log file.""" 

370 return self.logfile 

371 

372 def get_eigenmode_logfile(self): 

373 """Returns the eigenmode log file.""" 

374 return self.eigenmode_logfile 

375 

376 def get_counter(self, counter): 

377 """Returns a given counter.""" 

378 return self.counters[counter] 

379 

380 def increment_counter(self, counter): 

381 """Increment a given counter.""" 

382 self.counters[counter] += 1 

383 

384 def reset_counter(self, counter): 

385 """Reset a given counter.""" 

386 self.counters[counter] = 0 

387 

388 def reset_all_counters(self): 

389 """Reset all counters.""" 

390 for key in self.counters: 

391 self.counters[key] = 0 

392 

393 

394class DimerControl(MinModeControl): 

395 """A class that takes care of the parameters needed for a Dimer search. 

396 

397 Parameters 

398 ---------- 

399 

400 eigenmode_method: str 

401 The name of the eigenmode search method. 

402 f_rot_min: float 

403 Size of the rotational force under which no rotation will be 

404 performed. 

405 f_rot_max: float 

406 Size of the rotational force under which only one rotation will be 

407 performed. 

408 max_num_rot: int 

409 Maximum number of rotations per optimizer step. 

410 trial_angle: float 

411 Trial angle for the finite difference estimate of the rotational 

412 angle in radians. 

413 trial_trans_step: float 

414 Trial step size for the MinModeTranslate optimizer. 

415 maximum_translation: float 

416 Maximum step size and forced step size when the curvature is still 

417 positive for the MinModeTranslate optimizer. 

418 cg_translation: bool 

419 Conjugate Gradient for the MinModeTranslate optimizer. 

420 use_central_forces: bool 

421 Only calculate the forces at one end of the dimer and extrapolate 

422 the forces to the other. 

423 dimer_separation: float 

424 Separation of the dimer's images. 

425 initial_eigenmode_method: str 

426 How to construct the initial eigenmode of the dimer. If an eigenmode 

427 is given when creating the MinModeAtoms object, this will be ignored. 

428 Possible choices are: 'gauss' and 'displacement' 

429 extrapolate_forces: bool 

430 When more than one rotation is performed, an extrapolation scheme can 

431 be used to reduce the number of force evaluations. 

432 displacement_method: str 

433 How to displace the atoms. Possible choices are 'gauss' and 'vector'. 

434 gauss_std: float 

435 The standard deviation of the gauss curve used when doing random 

436 displacement. 

437 order: int 

438 How many lowest eigenmodes will be inverted. 

439 mask: list of bool 

440 Which atoms will be moved during displacement. 

441 displacement_center: int or [float, float, float] 

442 The center of displacement, nearby atoms will be displaced. 

443 displacement_radius: float 

444 When choosing which atoms to displace with the *displacement_center* 

445 keyword, this decides how many nearby atoms to displace. 

446 number_of_displacement_atoms: int 

447 The amount of atoms near *displacement_center* to displace. 

448 

449 """ 

450 # Default parameters for the Dimer eigenmode search 

451 parameters = {'eigenmode_method': 'dimer', 

452 'f_rot_min': 0.1, 

453 'f_rot_max': 1.00, 

454 'max_num_rot': 1, 

455 'trial_angle': pi / 4.0, 

456 'trial_trans_step': 0.001, 

457 'maximum_translation': 0.1, 

458 'cg_translation': True, 

459 'use_central_forces': True, 

460 'dimer_separation': 0.0001, 

461 'initial_eigenmode_method': 'gauss', 

462 'extrapolate_forces': False, 

463 'displacement_method': 'gauss', 

464 'gauss_std': 0.1, 

465 'order': 1, 

466 'mask': None, # NB mask should not be a "parameter" 

467 'displacement_center': None, 

468 'displacement_radius': None, 

469 'number_of_displacement_atoms': None} 

470 

471 # NB: Can maybe put this in EigenmodeSearch and MinModeControl 

472 def log(self, parameter=None): 

473 """Log the parameters of the eigenmode search.""" 

474 if self.logfile is not None: 

475 if parameter is not None: 

476 l = 'DIM:CONTROL: Updated Parameter: {} = {}\n'.format( 

477 parameter, str(self.get_parameter(parameter))) 

478 else: 

479 l = 'MINMODE:METHOD: Dimer\n' 

480 l += 'DIM:CONTROL: Search Parameters:\n' 

481 l += 'DIM:CONTROL: ------------------\n' 

482 for key in self.parameters: 

483 l += 'DIM:CONTROL: {} = {}\n'.format( 

484 key, str(self.get_parameter(key))) 

485 l += 'DIM:CONTROL: ------------------\n' 

486 l += 'DIM:ROT: OPT-STEP ROT-STEP CURVATURE ROT-ANGLE ' + \ 

487 'ROT-FORCE\n' 

488 self.logfile.write(l) 

489 self.logfile.flush() 

490 

491 

492class MinModeAtoms: 

493 """Wrapper for Atoms with information related to minimum mode searching. 

494 

495 Contains an Atoms object and pipes all unknown function calls to that 

496 object. 

497 Other information that is stored in this object are the estimate for 

498 the lowest eigenvalue, *curvature*, and its corresponding eigenmode, 

499 *eigenmode*. Furthermore, the original configuration of the Atoms 

500 object is stored for use in multiple minimum mode searches. 

501 The forces on the system are modified by inverting the component 

502 along the eigenmode estimate. This eventually brings the system to 

503 a saddle point. 

504 

505 [1]_ [2]_ [3]_ [4]_ 

506 

507 Parameters 

508 ---------- 

509 

510 atoms : Atoms object 

511 A regular Atoms object 

512 control : MinModeControl object 

513 Contains the parameters necessary for the eigenmode search. 

514 If no control object is supplied a default DimerControl 

515 will be created and used. 

516 mask: list of bool 

517 Determines which atoms will be moved when calling displace() 

518 random_seed: int 

519 The seed used for the random number generator. Defaults to 

520 modified version the current time. 

521 

522 References 

523 ---------- 

524 .. [1] Henkelman and Jonsson, JCP 111, 7010 (1999) 

525 .. [2] Olsen, Kroes, Henkelman, Arnaldsson, and Jonsson, JCP 121, 

526 9776 (2004). 

527 .. [3] Heyden, Bell, and Keil, JCP 123, 224101 (2005). 

528 .. [4] Kastner and Sherwood, JCP 128, 014106 (2008). 

529 

530 """ 

531 

532 def __init__(self, atoms, control=None, eigenmodes=None, 

533 random_seed=None, comm=world, **kwargs): 

534 self.minmode_init = True 

535 self.atoms = atoms 

536 

537 # Initialize to None to avoid strange behaviour due to __getattr__ 

538 self.eigenmodes = eigenmodes 

539 self.curvatures = None 

540 

541 if control is None: 

542 self.control = DimerControl(**kwargs) 

543 w = 'Missing control object in ' + self.__class__.__name__ + \ 

544 '. Using default: DimerControl()' 

545 warnings.warn(w, UserWarning) 

546 if self.control.logfile is not None: 

547 self.control.logfile.write('DIM:WARN: ' + w + '\n') 

548 self.control.logfile.flush() 

549 else: 

550 self.control = control 

551 logfile = self.control.get_logfile() 

552 mlogfile = self.control.get_eigenmode_logfile() 

553 for key in kwargs: 

554 if key == 'logfile': 

555 logfile = kwargs[key] 

556 elif key == 'eigenmode_logfile': 

557 mlogfile = kwargs[key] 

558 else: 

559 self.control.set_parameter(key, kwargs[key]) 

560 self.control.initialize_logfiles(comm=comm, logfile=logfile, 

561 eigenmode_logfile=mlogfile) 

562 

563 # Seed the randomness 

564 if random_seed is None: 

565 t = time.time() 

566 if world.size > 1: 

567 t = world.sum(t) / world.size 

568 # Harvest the latter part of the current time 

569 random_seed = int(('%30.9f' % t)[-9:]) 

570 self.random_state = np.random.RandomState(random_seed) 

571 

572 # Check the order 

573 self.order = self.control.get_parameter('order') 

574 

575 # Construct the curvatures list 

576 self.curvatures = [100.0] * self.order 

577 

578 # Save the original state of the atoms. 

579 self.atoms0 = self.atoms.copy() 

580 self.save_original_forces() 

581 

582 # Get a reference to the log files 

583 self.logfile = self.control.get_logfile() 

584 self.mlogfile = self.control.get_eigenmode_logfile() 

585 

586 def __ase_optimizable__(self): 

587 return DimerOptimizable(self) 

588 

589 def save_original_forces(self, force_calculation=False): 

590 """Store the forces (and energy) of the original state.""" 

591 # NB: Would be nice if atoms.copy() took care of this. 

592 if self.calc is not None: 

593 # Hack because some calculators do not have calculation_required 

594 if (hasattr(self.calc, 'calculation_required') 

595 and not self.calc.calculation_required(self.atoms, 

596 ['energy', 'forces'])) or force_calculation: 

597 calc = SinglePointCalculator( 

598 self.atoms0, 

599 energy=self.atoms.get_potential_energy(), 

600 forces=self.atoms.get_forces()) 

601 self.atoms0.calc = calc 

602 

603 def initialize_eigenmodes(self, method=None, eigenmodes=None, 

604 gauss_std=None): 

605 """Make an initial guess for the eigenmode.""" 

606 if eigenmodes is None: 

607 pos = self.get_positions() 

608 old_pos = self.get_original_positions() 

609 if method is None: 

610 method = \ 

611 self.control.get_parameter('initial_eigenmode_method') 

612 if method.lower() == 'displacement' and (pos - old_pos).any(): 

613 eigenmode = normalize(pos - old_pos) 

614 elif method.lower() == 'gauss': 

615 self.displace(log=False, gauss_std=gauss_std, 

616 method=method) 

617 new_pos = self.get_positions() 

618 eigenmode = normalize(new_pos - pos) 

619 self.set_positions(pos) 

620 else: 

621 e = 'initial_eigenmode must use either \'gauss\' or ' + \ 

622 '\'displacement\', if the latter is used the atoms ' + \ 

623 'must have moved away from the original positions.' + \ 

624 f'You have requested \'{method}\'.' 

625 raise NotImplementedError(e) # NYI 

626 eigenmodes = [eigenmode] 

627 

628 # Create random higher order mode guesses 

629 if self.order > 1: 

630 if len(eigenmodes) == 1: 

631 for _ in range(1, self.order): 

632 pos = self.get_positions() 

633 self.displace(log=False, gauss_std=gauss_std, 

634 method=method) 

635 new_pos = self.get_positions() 

636 eigenmode = normalize(new_pos - pos) 

637 self.set_positions(pos) 

638 eigenmodes += [eigenmode] 

639 

640 self.eigenmodes = eigenmodes 

641 # Ensure that the higher order mode guesses are all orthogonal 

642 if self.order > 1: 

643 for k in range(self.order): 

644 self.ensure_eigenmode_orthogonality(k) 

645 self.eigenmode_log() 

646 

647 # NB maybe this name might be confusing in context to 

648 # calc.calculation_required() 

649 def calculation_required(self): 

650 """Check if a calculation is required.""" 

651 return self.minmode_init or self.check_atoms != self.atoms 

652 

653 def calculate_real_forces_and_energies(self, **kwargs): 

654 """Calculate and store the potential energy and forces.""" 

655 if self.minmode_init: 

656 self.minmode_init = False 

657 self.initialize_eigenmodes(eigenmodes=self.eigenmodes) 

658 self.rotation_required = True 

659 self.forces0 = self.atoms.get_forces(**kwargs) 

660 self.energy0 = self.atoms.get_potential_energy() 

661 self.control.increment_counter('forcecalls') 

662 self.check_atoms = self.atoms.copy() 

663 

664 def get_potential_energy(self): 

665 """Return the potential energy.""" 

666 if self.calculation_required(): 

667 self.calculate_real_forces_and_energies() 

668 return self.energy0 

669 

670 def get_forces(self, real=False, pos=None, **kwargs): 

671 """Return the forces, projected or real.""" 

672 if self.calculation_required() and pos is None: 

673 self.calculate_real_forces_and_energies(**kwargs) 

674 if real and pos is None: 

675 return self.forces0 

676 elif real and pos is not None: 

677 old_pos = self.atoms.get_positions() 

678 self.atoms.set_positions(pos) 

679 forces = self.atoms.get_forces() 

680 self.control.increment_counter('forcecalls') 

681 self.atoms.set_positions(old_pos) 

682 return forces 

683 else: 

684 if self.rotation_required: 

685 self.find_eigenmodes(order=self.order) 

686 self.eigenmode_log() 

687 self.rotation_required = False 

688 self.control.increment_counter('optcount') 

689 return self.get_projected_forces() 

690 

691 def ensure_eigenmode_orthogonality(self, order): 

692 mode = self.eigenmodes[order - 1].copy() 

693 for k in range(order - 1): 

694 mode = perpendicular_vector(mode, self.eigenmodes[k]) 

695 self.eigenmodes[order - 1] = normalize(mode) 

696 

697 def find_eigenmodes(self, order=1): 

698 """Launch eigenmode searches.""" 

699 if self.control.get_parameter('eigenmode_method').lower() != 'dimer': 

700 e = 'Only the Dimer control object has been implemented.' 

701 raise NotImplementedError(e) # NYI 

702 for k in range(order): 

703 if k > 0: 

704 self.ensure_eigenmode_orthogonality(k + 1) 

705 search = DimerEigenmodeSearch( 

706 self, self.control, 

707 eigenmode=self.eigenmodes[k], basis=self.eigenmodes[:k]) 

708 search.converge_to_eigenmode() 

709 search.set_up_for_optimization_step() 

710 self.eigenmodes[k] = search.get_eigenmode() 

711 self.curvatures[k] = search.get_curvature() 

712 

713 def get_projected_forces(self, pos=None): 

714 """Return the projected forces.""" 

715 if pos is not None: 

716 forces = self.get_forces(real=True, pos=pos).copy() 

717 else: 

718 forces = self.forces0.copy() 

719 

720 # Loop through all the eigenmodes 

721 # NB: Can this be done with a linear combination, instead? 

722 for k, mode in enumerate(self.eigenmodes): 

723 # NYI This If statement needs to be overridable in the control 

724 if self.get_curvature(order=k) > 0.0 and self.order == 1: 

725 forces = -parallel_vector(forces, mode) 

726 else: 

727 forces -= 2 * parallel_vector(forces, mode) 

728 return forces 

729 

730 def restore_original_positions(self): 

731 """Restore the MinModeAtoms object positions to the original state.""" 

732 self.atoms.set_positions(self.get_original_positions()) 

733 

734 def get_barrier_energy(self): 

735 """The energy difference between the current and original states""" 

736 try: 

737 original_energy = self.get_original_potential_energy() 

738 dimer_energy = self.get_potential_energy() 

739 return dimer_energy - original_energy 

740 except RuntimeError: 

741 w = 'The potential energy is not available, without further ' + \ 

742 'calculations, most likely at the original state.' 

743 warnings.warn(w, UserWarning) 

744 return np.nan 

745 

746 def get_control(self): 

747 """Return the control object.""" 

748 return self.control 

749 

750 def get_curvature(self, order='max'): 

751 """Return the eigenvalue estimate.""" 

752 if order == 'max': 

753 return max(self.curvatures) 

754 else: 

755 return self.curvatures[order - 1] 

756 

757 def get_eigenmode(self, order=1): 

758 """Return the current eigenmode guess.""" 

759 return self.eigenmodes[order - 1] 

760 

761 def get_atoms(self): 

762 """Return the unextended Atoms object.""" 

763 return self.atoms 

764 

765 def set_atoms(self, atoms): 

766 """Set a new Atoms object""" 

767 self.atoms = atoms 

768 

769 def set_eigenmode(self, eigenmode, order=1): 

770 """Set the eigenmode guess.""" 

771 self.eigenmodes[order - 1] = eigenmode 

772 

773 def set_curvature(self, curvature, order=1): 

774 """Set the eigenvalue estimate.""" 

775 self.curvatures[order - 1] = curvature 

776 

777 # Pipe all the stuff from Atoms that is not overwritten. 

778 # Pipe all requests for get_original_* to self.atoms0. 

779 def __getattr__(self, attr): 

780 """Return any value of the Atoms object""" 

781 if 'original' in attr.split('_'): 

782 attr = attr.replace('_original_', '_') 

783 return getattr(self.atoms0, attr) 

784 else: 

785 return getattr(self.atoms, attr) 

786 

787 def __len__(self): 

788 return len(self.atoms) 

789 

790 def displace(self, displacement_vector=None, mask=None, method=None, 

791 displacement_center=None, radius=None, number_of_atoms=None, 

792 gauss_std=None, mic=True, log=True): 

793 """Move the atoms away from their current position. 

794 

795 This is one of the essential parts of minimum mode searches. 

796 The parameters can all be set in the control object and overwritten 

797 when this method is run, apart from *displacement_vector*. 

798 It is preferred to modify the control values rather than those here 

799 in order for the correct ones to show up in the log file. 

800 

801 *method* can be either 'gauss' for random displacement or 'vector' 

802 to perform a predefined displacement. 

803 

804 *gauss_std* is the standard deviation of the gauss curve that is 

805 used for random displacement. 

806 

807 *displacement_center* can be either the number of an atom or a 3D 

808 position. It must be accompanied by a *radius* (all atoms within it 

809 will be displaced) or a *number_of_atoms* which decides how many of 

810 the closest atoms will be displaced. 

811 

812 *mic* controls the usage of the Minimum Image Convention. 

813 

814 If both *mask* and *displacement_center* are used, the atoms marked 

815 as False in the *mask* will not be affected even though they are 

816 within reach of the *displacement_center*. 

817 

818 The parameters priority order: 

819 1) displacement_vector 

820 2) mask 

821 3) displacement_center (with radius and/or number_of_atoms) 

822 

823 If both *radius* and *number_of_atoms* are supplied with 

824 *displacement_center*, only atoms that fulfill both criteria will 

825 be displaced. 

826 

827 """ 

828 

829 # Fetch the default values from the control 

830 if mask is None: 

831 mask = self.control.get_parameter('mask') 

832 if method is None: 

833 method = self.control.get_parameter('displacement_method') 

834 if gauss_std is None: 

835 gauss_std = self.control.get_parameter('gauss_std') 

836 if displacement_center is None: 

837 displacement_center = \ 

838 self.control.get_parameter('displacement_center') 

839 if radius is None: 

840 radius = self.control.get_parameter('displacement_radius') 

841 if number_of_atoms is None: 

842 number_of_atoms = \ 

843 self.control.get_parameter('number_of_displacement_atoms') 

844 

845 # Check for conflicts 

846 if displacement_vector is not None and method.lower() != 'vector': 

847 e = 'displacement_vector was supplied but a different method ' + \ 

848 f'(\'{method!s}\') was chosen.\n' 

849 raise ValueError(e) 

850 elif displacement_vector is None and method.lower() == 'vector': 

851 e = 'A displacement_vector must be supplied when using ' + \ 

852 f'method = \'{method!s}\'.\n' 

853 raise ValueError(e) 

854 elif displacement_center is not None and radius is None and \ 

855 number_of_atoms is None: 

856 e = 'When displacement_center is chosen, either radius or ' + \ 

857 'number_of_atoms must be supplied.\n' 

858 raise ValueError(e) 

859 

860 # Set up the center of displacement mask (c_mask) 

861 if displacement_center is not None: 

862 c = displacement_center 

863 # Construct a distance list 

864 # The center is an atom 

865 if isinstance(c, int): 

866 # Parse negative indexes 

867 c = displacement_center % len(self) 

868 d = [(k, self.get_distance(k, c, mic=mic)) for k in 

869 range(len(self))] 

870 # The center is a position in 3D space 

871 elif len(c) == 3 and [type(c_k) for c_k in c] == [float] * 3: 

872 # NB: MIC is not considered. 

873 d = [(k, norm(self.get_positions()[k] - c)) 

874 for k in range(len(self))] 

875 else: 

876 e = 'displacement_center must be either the number of an ' + \ 

877 'atom in MinModeAtoms object or a 3D position ' + \ 

878 '(3-tuple of floats).' 

879 raise ValueError(e) 

880 

881 # Set up the mask 

882 if radius is not None: 

883 r_mask = [dist[1] < radius for dist in d] 

884 else: 

885 r_mask = [True for _ in range(len(self))] 

886 

887 if number_of_atoms is not None: 

888 d_sorted = [n[0] for n in sorted(d, key=lambda k: k[1])] 

889 n_nearest = d_sorted[:number_of_atoms] 

890 n_mask = [k in n_nearest for k in range(len(self))] 

891 else: 

892 n_mask = [True for _ in range(len(self))] 

893 

894 # Resolve n_mask / r_mask conflicts 

895 c_mask = [n_mask[k] and r_mask[k] for k in range(len(self))] 

896 else: 

897 c_mask = None 

898 

899 # Set up a True mask if there is no mask supplied 

900 if mask is None: 

901 mask = [True for _ in range(len(self))] 

902 if c_mask is None: 

903 w = 'It was not possible to figure out which atoms to ' + \ 

904 'displace, Will try to displace all atoms.\n' 

905 warnings.warn(w, UserWarning) 

906 if self.logfile is not None: 

907 self.logfile.write('MINMODE:WARN: ' + w + '\n') 

908 self.logfile.flush() 

909 

910 # Resolve mask / c_mask conflicts 

911 if c_mask is not None: 

912 mask = [mask[k] and c_mask[k] for k in range(len(self))] 

913 

914 if displacement_vector is None: 

915 displacement_vector = [] 

916 for k in range(len(self)): 

917 if mask[k]: 

918 diff_line = [] 

919 for _ in range(3): 

920 if method.lower() == 'gauss': 

921 if not gauss_std: 

922 gauss_std = \ 

923 self.control.get_parameter('gauss_std') 

924 diff = self.random_state.normal(0.0, gauss_std) 

925 else: 

926 e = f'Invalid displacement method >>{method!s}<<' 

927 raise ValueError(e) 

928 diff_line.append(diff) 

929 displacement_vector.append(diff_line) 

930 else: 

931 displacement_vector.append([0.0] * 3) 

932 

933 # Remove displacement of masked atoms 

934 for k in range(len(mask)): 

935 if not mask[k]: 

936 displacement_vector[k] = [0.0] * 3 

937 

938 # Perform the displacement and log it 

939 if log: 

940 pos0 = self.get_positions() 

941 self.set_positions(self.get_positions() + displacement_vector) 

942 if log: 

943 parameters = {'mask': mask, 

944 'displacement_method': method, 

945 'gauss_std': gauss_std, 

946 'displacement_center': displacement_center, 

947 'displacement_radius': radius, 

948 'number_of_displacement_atoms': number_of_atoms} 

949 self.displacement_log(self.get_positions() - pos0, parameters) 

950 

951 def eigenmode_log(self): 

952 """Log the eigenmodes (eigenmode estimates)""" 

953 if self.mlogfile is not None: 

954 l = 'MINMODE:MODE: Optimization Step: %i\n' % \ 

955 (self.control.get_counter('optcount')) 

956 for m_num, mode in enumerate(self.eigenmodes): 

957 l += 'MINMODE:MODE: Order: %i\n' % m_num 

958 for k in range(len(mode)): 

959 l += 'MINMODE:MODE: %7i %15.8f %15.8f %15.8f\n' % ( 

960 k, mode[k][0], mode[k][1], mode[k][2]) 

961 self.mlogfile.write(l) 

962 self.mlogfile.flush() 

963 

964 def displacement_log(self, displacement_vector, parameters): 

965 """Log the displacement""" 

966 if self.logfile is not None: 

967 lp = 'MINMODE:DISP: Parameters, different from the control:\n' 

968 mod_para = False 

969 for key in parameters: 

970 if parameters[key] != self.control.get_parameter(key): 

971 lp += 'MINMODE:DISP: {} = {}\n'.format(str(key), 

972 str(parameters[key])) 

973 mod_para = True 

974 if mod_para: 

975 l = lp 

976 else: 

977 l = '' 

978 for k in range(len(displacement_vector)): 

979 l += 'MINMODE:DISP: %7i %15.8f %15.8f %15.8f\n' % ( 

980 k, 

981 displacement_vector[k][0], displacement_vector[k][1], 

982 displacement_vector[k][2]) 

983 self.logfile.write(l) 

984 self.logfile.flush() 

985 

986 def summarize(self): 

987 """Summarize the Minimum mode search.""" 

988 if self.logfile is None: 

989 logfile = sys.stdout 

990 else: 

991 logfile = self.logfile 

992 

993 c = self.control 

994 label = 'MINMODE:SUMMARY: ' 

995 

996 l = label + '-------------------------\n' 

997 l += label + 'Barrier: %16.4f\n' % self.get_barrier_energy() 

998 l += label + 'Curvature: %14.4f\n' % self.get_curvature() 

999 l += label + 'Optimizer steps: %8i\n' % c.get_counter('optcount') 

1000 l += label + 'Forcecalls: %13i\n' % c.get_counter('forcecalls') 

1001 l += label + '-------------------------\n' 

1002 

1003 logfile.write(l) 

1004 

1005 

1006class MinModeTranslate(Optimizer): 

1007 """An Optimizer specifically tailored to minimum mode following.""" 

1008 

1009 def __init__(self, dimeratoms, logfile='-', trajectory=None): 

1010 Optimizer.__init__(self, dimeratoms, None, logfile, trajectory) 

1011 

1012 self.control = dimeratoms.get_control() 

1013 self.dimeratoms = dimeratoms 

1014 

1015 # Make a header for the log 

1016 if self.logfile is not None: 

1017 l = '' 

1018 if isinstance(self.control, DimerControl): 

1019 l = 'MinModeTranslate: STEP TIME ENERGY ' + \ 

1020 'MAX-FORCE STEPSIZE CURVATURE ROT-STEPS\n' 

1021 self.logfile.write(l) 

1022 

1023 # Load the relevant parameters from control 

1024 self.cg_on = self.control.get_parameter('cg_translation') 

1025 self.trial_step = self.control.get_parameter('trial_trans_step') 

1026 self.max_step = self.control.get_parameter('maximum_translation') 

1027 

1028 # Start conjugate gradient 

1029 if self.cg_on: 

1030 self.cg_init = True 

1031 

1032 def initialize(self): 

1033 """Set initial values.""" 

1034 self.r0 = None 

1035 self.f0 = None 

1036 

1037 def step(self, f=None): 

1038 """Perform the optimization step.""" 

1039 

1040 atoms = self.dimeratoms 

1041 if f is None: 

1042 f = atoms.get_forces() 

1043 r = atoms.get_positions() 

1044 curv = atoms.get_curvature() 

1045 f0p = f.copy() 

1046 r0 = r.copy() 

1047 direction = f0p.copy() 

1048 if self.cg_on: 

1049 direction = self.get_cg_direction(direction) 

1050 direction = normalize(direction) 

1051 if curv > 0.0: 

1052 step = direction * self.max_step 

1053 else: 

1054 r0t = r0 + direction * self.trial_step 

1055 f0tp = self.dimeratoms.get_projected_forces(r0t) 

1056 F = np.vdot((f0tp + f0p), direction) / 2.0 

1057 C = np.vdot((f0tp - f0p), direction) / self.trial_step 

1058 step = (-F / C + self.trial_step / 2.0) * direction 

1059 if norm(step) > self.max_step: 

1060 step = direction * self.max_step 

1061 self.log(f0p, norm(step)) 

1062 

1063 atoms.set_positions(r + step) 

1064 

1065 self.f0 = f.flat.copy() 

1066 self.r0 = r.flat.copy() 

1067 

1068 def get_cg_direction(self, direction): 

1069 """Apply the Conjugate Gradient algorithm to the step direction.""" 

1070 if self.cg_init: 

1071 self.cg_init = False 

1072 self.direction_old = direction.copy() 

1073 self.cg_direction = direction.copy() 

1074 old_norm = np.vdot(self.direction_old, self.direction_old) 

1075 # Polak-Ribiere Conjugate Gradient 

1076 if old_norm != 0.0: 

1077 betaPR = np.vdot(direction, (direction - self.direction_old)) / \ 

1078 old_norm 

1079 else: 

1080 betaPR = 0.0 

1081 if betaPR < 0.0: 

1082 betaPR = 0.0 

1083 self.cg_direction = direction + self.cg_direction * betaPR 

1084 self.direction_old = direction.copy() 

1085 return self.cg_direction.copy() 

1086 

1087 def log(self, gradient, stepsize=None): 

1088 """Log each step of the optimization.""" 

1089 f = self.dimeratoms.get_forces() 

1090 if self.logfile is not None: 

1091 T = time.localtime() 

1092 e = self.dimeratoms.get_potential_energy() 

1093 fmax = sqrt((f**2).sum(axis=1).max()) 

1094 rotsteps = self.dimeratoms.control.get_counter('rotcount') 

1095 curvature = self.dimeratoms.get_curvature() 

1096 l = '' 

1097 if stepsize: 

1098 if isinstance(self.control, DimerControl): 

1099 l = '%s: %4d %02d:%02d:%02d %15.6f %12.4f %12.6f ' \ 

1100 '%12.6f %10d\n' % ( 

1101 'MinModeTranslate', self.nsteps, 

1102 T[3], T[4], T[5], e, fmax, stepsize, curvature, 

1103 rotsteps) 

1104 else: 

1105 if isinstance(self.control, DimerControl): 

1106 l = '%s: %4d %02d:%02d:%02d %15.6f %12.4f %s ' \ 

1107 '%12.6f %10d\n' % ( 

1108 'MinModeTranslate', self.nsteps, 

1109 T[3], T[4], T[5], e, fmax, ' --------', 

1110 curvature, rotsteps) 

1111 self.logfile.write(l) 

1112 

1113 

1114def read_eigenmode(mlog, index=-1): 

1115 """Read an eigenmode. 

1116 To access the pre optimization eigenmode set index = 'null'. 

1117 

1118 """ 

1119 mlog_is_str = isinstance(mlog, str) 

1120 if mlog_is_str: 

1121 fd = open(mlog) 

1122 else: 

1123 fd = mlog 

1124 

1125 lines = fd.readlines() 

1126 

1127 # Detect the amount of atoms and iterations 

1128 k = 2 

1129 while lines[k].split()[1].lower() not in ['optimization', 'order']: 

1130 k += 1 

1131 n = k - 2 

1132 n_itr = (len(lines) // (n + 1)) - 2 

1133 

1134 # Locate the correct image. 

1135 if isinstance(index, str): 

1136 if index.lower() == 'null': 

1137 i = 0 

1138 else: 

1139 i = int(index) + 1 

1140 else: 

1141 if index >= 0: 

1142 i = index + 1 

1143 else: 

1144 if index < -n_itr - 1: 

1145 raise IndexError('list index out of range') 

1146 else: 

1147 i = index 

1148 

1149 mode = np.ndarray(shape=(n, 3), dtype=float) 

1150 for k_atom, k in enumerate(range(1, n + 1)): 

1151 line = lines[i * (n + 1) + k].split() 

1152 for k_dim in range(3): 

1153 mode[k_atom][k_dim] = float(line[k_dim + 2]) 

1154 if mlog_is_str: 

1155 fd.close() 

1156 

1157 return mode 

1158 

1159 

1160# Aliases 

1161DimerAtoms = MinModeAtoms 

1162DimerTranslate = MinModeTranslate