Coverage for /builds/ase/ase/ase/mep/dimer.py: 71.94%

588 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2025-08-02 00:12 +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, Dict 

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 atoms: MinModeAtoms object 

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

70 information about the lowest eigenvalue mode. 

71 control: DimerControl object 

72 Contains the parameters necessary for the eigenmode search. 

73 If no control object is supplied a default DimerControl 

74 will be created and used. 

75 basis: list of xyz-values 

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

77 It is possible to constrain the eigenmodes to be orthogonal 

78 to this given eigenmode. 

79 

80 Notes: 

81 

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

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

84 

85 References: 

86 

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

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

89 9776 (2004). 

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

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

92 

93 """ 

94 

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

96 **kwargs): 

97 if hasattr(dimeratoms, 'get_eigenmode'): 

98 self.dimeratoms = dimeratoms 

99 else: 

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

101 raise TypeError(e) 

102 self.basis = basis 

103 

104 if eigenmode is None: 

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

106 else: 

107 self.eigenmode = eigenmode 

108 

109 if control is None: 

110 self.control = DimerControl(**kwargs) 

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

112 '. Using default: DimerControl()' 

113 warnings.warn(w, UserWarning) 

114 if self.control.logfile is not None: 

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

116 self.control.logfile.flush() 

117 else: 

118 self.control = control 

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

120 for key in kwargs: 

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

122 raise TypeError(e) 

123 

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

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

126 

127 def converge_to_eigenmode(self): 

128 """Perform an eigenmode search.""" 

129 self.set_up_for_eigenmode_search() 

130 stoprot = False 

131 

132 # Load the relevant parameters from control 

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

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

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

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

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

138 

139 while not stoprot: 

140 if self.forces1E is None: 

141 self.update_virtual_forces() 

142 else: 

143 self.update_virtual_forces(extrapolated_forces=True) 

144 self.forces1A = self.forces1 

145 self.update_curvature() 

146 f_rot_A = self.get_rotational_force() 

147 

148 # Pre rotation stop criteria 

149 if norm(f_rot_A) <= f_rot_min: 

150 self.log(f_rot_A, None) 

151 stoprot = True 

152 else: 

153 n_A = self.eigenmode 

154 rot_unit_A = normalize(f_rot_A) 

155 

156 # Get the curvature and its derivative 

157 c0 = self.get_curvature() 

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

159 self.dR 

160 

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

162 # NYI variable trial angles from [3] 

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

164 self.eigenmode = n_B 

165 self.update_virtual_forces() 

166 self.forces1B = self.forces1 

167 

168 # Get the curvature's derivative 

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

170 self.dR 

171 

172 # Calculate the Fourier coefficients 

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

174 (2 * sin(2 * trial_angle)) 

175 b1 = 0.5 * c0d 

176 a0 = 2 * (c0 - a1) 

177 

178 # Estimate the rotational angle 

179 rotangle = atan(b1 / a1) / 2.0 

180 

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

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

183 b1 * sin(2 * rotangle) 

184 if c0 < cmin: 

185 rotangle += pi / 2.0 

186 

187 # Rotate into the (hopefully) lowest eigenmode 

188 # NYI Conjugate gradient rotation 

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

190 self.update_eigenmode(n_min) 

191 

192 # Store the curvature estimate instead of the old curvature 

193 self.update_curvature(cmin) 

194 

195 self.log(f_rot_A, rotangle) 

196 

197 # Force extrapolation scheme from [4] 

198 if extrapolate: 

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

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

201 sin(trial_angle) * self.forces1B + \ 

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

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

204 else: 

205 self.forces1E = None 

206 

207 # Post rotation stop criteria 

208 if not stoprot: 

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

210 stoprot = True 

211 elif norm(f_rot_A) <= f_rot_max: 

212 stoprot = True 

213 

214 def log(self, f_rot_A, angle): 

215 """Log each rotational step.""" 

216 # NYI Log for the trial angle 

217 if self.logfile is not None: 

218 if angle: 

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

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

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

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

223 else: 

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

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

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

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

228 self.logfile.write(l) 

229 self.logfile.flush() 

230 

231 def get_rotational_force(self): 

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

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

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

235 if self.basis is not None: 

236 if ( 

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

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

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

240 rot_force = perpendicular_vector(rot_force, self.basis) 

241 else: 

242 for base in self.basis: 

243 rot_force = perpendicular_vector(rot_force, base) 

244 return rot_force 

245 

246 def update_curvature(self, curv=None): 

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

248 if curv: 

249 self.curvature = curv 

250 else: 

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

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

253 

254 def update_eigenmode(self, eigenmode): 

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

256 self.eigenmode = eigenmode 

257 self.update_virtual_positions() 

258 self.control.increment_counter('rotcount') 

259 

260 def get_eigenmode(self): 

261 """Returns the current eigenmode.""" 

262 return self.eigenmode 

263 

264 def get_curvature(self): 

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

266 return self.curvature 

267 

268 def get_control(self): 

269 """Return the control object.""" 

270 return self.control 

271 

272 def update_center_forces(self): 

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

274 self.dimeratoms.set_positions(self.pos0) 

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

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

277 

278 def update_virtual_forces(self, extrapolated_forces=False): 

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

280 self.update_virtual_positions() 

281 

282 # Estimate / Calculate the forces at pos1 

283 if extrapolated_forces: 

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

285 else: 

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

287 

288 # Estimate / Calculate the forces at pos2 

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

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

291 else: 

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

293 

294 def update_virtual_positions(self): 

295 """Update the end point positions.""" 

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

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

298 

299 def set_up_for_eigenmode_search(self): 

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

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

302 self.update_center_forces() 

303 self.update_virtual_positions() 

304 self.control.reset_counter('rotcount') 

305 self.forces1E = None 

306 

307 def set_up_for_optimization_step(self): 

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

309 self.dimeratoms.set_positions(self.pos0) 

310 self.forces1E = None 

311 

312 

313class MinModeControl(IOContext): 

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

315 

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

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

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

319 parameters needed by the method in question. 

320 When instantiating control classes default parameter values can 

321 be overwritten. 

322 

323 """ 

324 parameters: Dict[str, Any] = {} 

325 

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

327 **kwargs): 

328 # Overwrite the defaults with the input parameters given 

329 for key in kwargs: 

330 if key not in self.parameters: 

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

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

333 raise ValueError(e) 

334 else: 

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

336 

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

338 eigenmode_logfile=eigenmode_logfile) 

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

340 self.log() 

341 

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

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

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

345 comm=comm) 

346 

347 def log(self, parameter=None): 

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

349 

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

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

352 if parameter not in self.parameters: 

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

354 raise ValueError(e) 

355 self.parameters[parameter] = value 

356 if log: 

357 self.log(parameter) 

358 

359 def get_parameter(self, parameter): 

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

361 if parameter not in self.parameters: 

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

363 raise ValueError(e) 

364 return self.parameters[parameter] 

365 

366 def get_logfile(self): 

367 """Returns the log file.""" 

368 return self.logfile 

369 

370 def get_eigenmode_logfile(self): 

371 """Returns the eigenmode log file.""" 

372 return self.eigenmode_logfile 

373 

374 def get_counter(self, counter): 

375 """Returns a given counter.""" 

376 return self.counters[counter] 

377 

378 def increment_counter(self, counter): 

379 """Increment a given counter.""" 

380 self.counters[counter] += 1 

381 

382 def reset_counter(self, counter): 

383 """Reset a given counter.""" 

384 self.counters[counter] = 0 

385 

386 def reset_all_counters(self): 

387 """Reset all counters.""" 

388 for key in self.counters: 

389 self.counters[key] = 0 

390 

391 

392class DimerControl(MinModeControl): 

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

394 

395 Parameters: 

396 

397 eigenmode_method: str 

398 The name of the eigenmode search method. 

399 f_rot_min: float 

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

401 performed. 

402 f_rot_max: float 

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

404 performed. 

405 max_num_rot: int 

406 Maximum number of rotations per optimizer step. 

407 trial_angle: float 

408 Trial angle for the finite difference estimate of the rotational 

409 angle in radians. 

410 trial_trans_step: float 

411 Trial step size for the MinModeTranslate optimizer. 

412 maximum_translation: float 

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

414 positive for the MinModeTranslate optimizer. 

415 cg_translation: bool 

416 Conjugate Gradient for the MinModeTranslate optimizer. 

417 use_central_forces: bool 

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

419 the forces to the other. 

420 dimer_separation: float 

421 Separation of the dimer's images. 

422 initial_eigenmode_method: str 

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

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

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

426 extrapolate_forces: bool 

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

428 be used to reduce the number of force evaluations. 

429 displacement_method: str 

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

431 gauss_std: float 

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

433 displacement. 

434 order: int 

435 How many lowest eigenmodes will be inverted. 

436 mask: list of bool 

437 Which atoms will be moved during displacement. 

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

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

440 displacement_radius: float 

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

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

443 number_of_displacement_atoms: int 

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

445 

446 """ 

447 # Default parameters for the Dimer eigenmode search 

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

449 'f_rot_min': 0.1, 

450 'f_rot_max': 1.00, 

451 'max_num_rot': 1, 

452 'trial_angle': pi / 4.0, 

453 'trial_trans_step': 0.001, 

454 'maximum_translation': 0.1, 

455 'cg_translation': True, 

456 'use_central_forces': True, 

457 'dimer_separation': 0.0001, 

458 'initial_eigenmode_method': 'gauss', 

459 'extrapolate_forces': False, 

460 'displacement_method': 'gauss', 

461 'gauss_std': 0.1, 

462 'order': 1, 

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

464 'displacement_center': None, 

465 'displacement_radius': None, 

466 'number_of_displacement_atoms': None} 

467 

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

469 def log(self, parameter=None): 

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

471 if self.logfile is not None: 

472 if parameter is not None: 

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

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

475 else: 

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

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

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

479 for key in self.parameters: 

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

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

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

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

484 'ROT-FORCE\n' 

485 self.logfile.write(l) 

486 self.logfile.flush() 

487 

488 

489class MinModeAtoms: 

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

491 

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

493 object. 

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

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

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

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

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

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

500 a saddle point. 

501 

502 Parameters: 

503 

504 atoms : Atoms object 

505 A regular Atoms object 

506 control : MinModeControl object 

507 Contains the parameters necessary for the eigenmode search. 

508 If no control object is supplied a default DimerControl 

509 will be created and used. 

510 mask: list of bool 

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

512 random_seed: int 

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

514 modified version the current time. 

515 

516 References: [1]_ [2]_ [3]_ [4]_ 

517 

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

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

520 9776 (2004). 

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

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

523 

524 """ 

525 

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

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

528 self.minmode_init = True 

529 self.atoms = atoms 

530 

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

532 self.eigenmodes = eigenmodes 

533 self.curvatures = None 

534 

535 if control is None: 

536 self.control = DimerControl(**kwargs) 

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

538 '. Using default: DimerControl()' 

539 warnings.warn(w, UserWarning) 

540 if self.control.logfile is not None: 

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

542 self.control.logfile.flush() 

543 else: 

544 self.control = control 

545 logfile = self.control.get_logfile() 

546 mlogfile = self.control.get_eigenmode_logfile() 

547 for key in kwargs: 

548 if key == 'logfile': 

549 logfile = kwargs[key] 

550 elif key == 'eigenmode_logfile': 

551 mlogfile = kwargs[key] 

552 else: 

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

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

555 eigenmode_logfile=mlogfile) 

556 

557 # Seed the randomness 

558 if random_seed is None: 

559 t = time.time() 

560 if world.size > 1: 

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

562 # Harvest the latter part of the current time 

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

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

565 

566 # Check the order 

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

568 

569 # Construct the curvatures list 

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

571 

572 # Save the original state of the atoms. 

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

574 self.save_original_forces() 

575 

576 # Get a reference to the log files 

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

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

579 

580 def __ase_optimizable__(self): 

581 return DimerOptimizable(self) 

582 

583 def save_original_forces(self, force_calculation=False): 

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

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

586 if self.calc is not None: 

587 # Hack because some calculators do not have calculation_required 

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

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

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

591 calc = SinglePointCalculator( 

592 self.atoms0, 

593 energy=self.atoms.get_potential_energy(), 

594 forces=self.atoms.get_forces()) 

595 self.atoms0.calc = calc 

596 

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

598 gauss_std=None): 

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

600 if eigenmodes is None: 

601 pos = self.get_positions() 

602 old_pos = self.get_original_positions() 

603 if method is None: 

604 method = \ 

605 self.control.get_parameter('initial_eigenmode_method') 

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

607 eigenmode = normalize(pos - old_pos) 

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

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

610 method=method) 

611 new_pos = self.get_positions() 

612 eigenmode = normalize(new_pos - pos) 

613 self.set_positions(pos) 

614 else: 

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

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

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

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

619 raise NotImplementedError(e) # NYI 

620 eigenmodes = [eigenmode] 

621 

622 # Create random higher order mode guesses 

623 if self.order > 1: 

624 if len(eigenmodes) == 1: 

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

626 pos = self.get_positions() 

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

628 method=method) 

629 new_pos = self.get_positions() 

630 eigenmode = normalize(new_pos - pos) 

631 self.set_positions(pos) 

632 eigenmodes += [eigenmode] 

633 

634 self.eigenmodes = eigenmodes 

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

636 if self.order > 1: 

637 for k in range(self.order): 

638 self.ensure_eigenmode_orthogonality(k) 

639 self.eigenmode_log() 

640 

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

642 # calc.calculation_required() 

643 def calculation_required(self): 

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

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

646 

647 def calculate_real_forces_and_energies(self, **kwargs): 

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

649 if self.minmode_init: 

650 self.minmode_init = False 

651 self.initialize_eigenmodes(eigenmodes=self.eigenmodes) 

652 self.rotation_required = True 

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

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

655 self.control.increment_counter('forcecalls') 

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

657 

658 def get_potential_energy(self): 

659 """Return the potential energy.""" 

660 if self.calculation_required(): 

661 self.calculate_real_forces_and_energies() 

662 return self.energy0 

663 

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

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

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

667 self.calculate_real_forces_and_energies(**kwargs) 

668 if real and pos is None: 

669 return self.forces0 

670 elif real and pos is not None: 

671 old_pos = self.atoms.get_positions() 

672 self.atoms.set_positions(pos) 

673 forces = self.atoms.get_forces() 

674 self.control.increment_counter('forcecalls') 

675 self.atoms.set_positions(old_pos) 

676 return forces 

677 else: 

678 if self.rotation_required: 

679 self.find_eigenmodes(order=self.order) 

680 self.eigenmode_log() 

681 self.rotation_required = False 

682 self.control.increment_counter('optcount') 

683 return self.get_projected_forces() 

684 

685 def ensure_eigenmode_orthogonality(self, order): 

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

687 for k in range(order - 1): 

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

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

690 

691 def find_eigenmodes(self, order=1): 

692 """Launch eigenmode searches.""" 

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

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

695 raise NotImplementedError(e) # NYI 

696 for k in range(order): 

697 if k > 0: 

698 self.ensure_eigenmode_orthogonality(k + 1) 

699 search = DimerEigenmodeSearch( 

700 self, self.control, 

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

702 search.converge_to_eigenmode() 

703 search.set_up_for_optimization_step() 

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

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

706 

707 def get_projected_forces(self, pos=None): 

708 """Return the projected forces.""" 

709 if pos is not None: 

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

711 else: 

712 forces = self.forces0.copy() 

713 

714 # Loop through all the eigenmodes 

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

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

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

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

719 forces = -parallel_vector(forces, mode) 

720 else: 

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

722 return forces 

723 

724 def restore_original_positions(self): 

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

726 self.atoms.set_positions(self.get_original_positions()) 

727 

728 def get_barrier_energy(self): 

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

730 try: 

731 original_energy = self.get_original_potential_energy() 

732 dimer_energy = self.get_potential_energy() 

733 return dimer_energy - original_energy 

734 except RuntimeError: 

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

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

737 warnings.warn(w, UserWarning) 

738 return np.nan 

739 

740 def get_control(self): 

741 """Return the control object.""" 

742 return self.control 

743 

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

745 """Return the eigenvalue estimate.""" 

746 if order == 'max': 

747 return max(self.curvatures) 

748 else: 

749 return self.curvatures[order - 1] 

750 

751 def get_eigenmode(self, order=1): 

752 """Return the current eigenmode guess.""" 

753 return self.eigenmodes[order - 1] 

754 

755 def get_atoms(self): 

756 """Return the unextended Atoms object.""" 

757 return self.atoms 

758 

759 def set_atoms(self, atoms): 

760 """Set a new Atoms object""" 

761 self.atoms = atoms 

762 

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

764 """Set the eigenmode guess.""" 

765 self.eigenmodes[order - 1] = eigenmode 

766 

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

768 """Set the eigenvalue estimate.""" 

769 self.curvatures[order - 1] = curvature 

770 

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

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

773 def __getattr__(self, attr): 

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

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

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

777 return getattr(self.atoms0, attr) 

778 else: 

779 return getattr(self.atoms, attr) 

780 

781 def __len__(self): 

782 return len(self.atoms) 

783 

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

785 displacement_center=None, radius=None, number_of_atoms=None, 

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

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

788 

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

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

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

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

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

794 

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

796 to perform a predefined displacement. 

797 

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

799 used for random displacement. 

800 

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

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

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

804 the closest atoms will be displaced. 

805 

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

807 

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

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

810 within reach of the *displacement_center*. 

811 

812 The parameters priority order: 

813 1) displacement_vector 

814 2) mask 

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

816 

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

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

819 be displaced. 

820 

821 """ 

822 

823 # Fetch the default values from the control 

824 if mask is None: 

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

826 if method is None: 

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

828 if gauss_std is None: 

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

830 if displacement_center is None: 

831 displacement_center = \ 

832 self.control.get_parameter('displacement_center') 

833 if radius is None: 

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

835 if number_of_atoms is None: 

836 number_of_atoms = \ 

837 self.control.get_parameter('number_of_displacement_atoms') 

838 

839 # Check for conflicts 

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

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

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

843 raise ValueError(e) 

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

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

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

847 raise ValueError(e) 

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

849 number_of_atoms is None: 

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

851 'number_of_atoms must be supplied.\n' 

852 raise ValueError(e) 

853 

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

855 if displacement_center is not None: 

856 c = displacement_center 

857 # Construct a distance list 

858 # The center is an atom 

859 if isinstance(c, int): 

860 # Parse negative indexes 

861 c = displacement_center % len(self) 

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

863 range(len(self))] 

864 # The center is a position in 3D space 

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

866 # NB: MIC is not considered. 

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

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

869 else: 

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

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

872 '(3-tuple of floats).' 

873 raise ValueError(e) 

874 

875 # Set up the mask 

876 if radius is not None: 

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

878 else: 

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

880 

881 if number_of_atoms is not None: 

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

883 n_nearest = d_sorted[:number_of_atoms] 

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

885 else: 

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

887 

888 # Resolve n_mask / r_mask conflicts 

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

890 else: 

891 c_mask = None 

892 

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

894 if mask is None: 

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

896 if c_mask is None: 

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

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

899 warnings.warn(w, UserWarning) 

900 if self.logfile is not None: 

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

902 self.logfile.flush() 

903 

904 # Resolve mask / c_mask conflicts 

905 if c_mask is not None: 

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

907 

908 if displacement_vector is None: 

909 displacement_vector = [] 

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

911 if mask[k]: 

912 diff_line = [] 

913 for _ in range(3): 

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

915 if not gauss_std: 

916 gauss_std = \ 

917 self.control.get_parameter('gauss_std') 

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

919 else: 

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

921 raise ValueError(e) 

922 diff_line.append(diff) 

923 displacement_vector.append(diff_line) 

924 else: 

925 displacement_vector.append([0.0] * 3) 

926 

927 # Remove displacement of masked atoms 

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

929 if not mask[k]: 

930 displacement_vector[k] = [0.0] * 3 

931 

932 # Perform the displacement and log it 

933 if log: 

934 pos0 = self.get_positions() 

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

936 if log: 

937 parameters = {'mask': mask, 

938 'displacement_method': method, 

939 'gauss_std': gauss_std, 

940 'displacement_center': displacement_center, 

941 'displacement_radius': radius, 

942 'number_of_displacement_atoms': number_of_atoms} 

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

944 

945 def eigenmode_log(self): 

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

947 if self.mlogfile is not None: 

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

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

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

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

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

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

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

955 self.mlogfile.write(l) 

956 self.mlogfile.flush() 

957 

958 def displacement_log(self, displacement_vector, parameters): 

959 """Log the displacement""" 

960 if self.logfile is not None: 

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

962 mod_para = False 

963 for key in parameters: 

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

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

966 str(parameters[key])) 

967 mod_para = True 

968 if mod_para: 

969 l = lp 

970 else: 

971 l = '' 

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

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

974 k, 

975 displacement_vector[k][0], displacement_vector[k][1], 

976 displacement_vector[k][2]) 

977 self.logfile.write(l) 

978 self.logfile.flush() 

979 

980 def summarize(self): 

981 """Summarize the Minimum mode search.""" 

982 if self.logfile is None: 

983 logfile = sys.stdout 

984 else: 

985 logfile = self.logfile 

986 

987 c = self.control 

988 label = 'MINMODE:SUMMARY: ' 

989 

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

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

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

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

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

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

996 

997 logfile.write(l) 

998 

999 

1000class MinModeTranslate(Optimizer): 

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

1002 

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

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

1005 

1006 self.control = dimeratoms.get_control() 

1007 self.dimeratoms = dimeratoms 

1008 

1009 # Make a header for the log 

1010 if self.logfile is not None: 

1011 l = '' 

1012 if isinstance(self.control, DimerControl): 

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

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

1015 self.logfile.write(l) 

1016 self.logfile.flush() 

1017 

1018 # Load the relevant parameters from control 

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

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

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

1022 

1023 # Start conjugate gradient 

1024 if self.cg_on: 

1025 self.cg_init = True 

1026 

1027 def initialize(self): 

1028 """Set initial values.""" 

1029 self.r0 = None 

1030 self.f0 = None 

1031 

1032 def step(self, f=None): 

1033 """Perform the optimization step.""" 

1034 atoms = self.dimeratoms 

1035 if f is None: 

1036 f = atoms.get_forces() 

1037 r = atoms.get_positions() 

1038 curv = atoms.get_curvature() 

1039 f0p = f.copy() 

1040 r0 = r.copy() 

1041 direction = f0p.copy() 

1042 if self.cg_on: 

1043 direction = self.get_cg_direction(direction) 

1044 direction = normalize(direction) 

1045 if curv > 0.0: 

1046 step = direction * self.max_step 

1047 else: 

1048 r0t = r0 + direction * self.trial_step 

1049 f0tp = self.dimeratoms.get_projected_forces(r0t) 

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

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

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

1053 if norm(step) > self.max_step: 

1054 step = direction * self.max_step 

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

1056 

1057 atoms.set_positions(r + step) 

1058 

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

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

1061 

1062 def get_cg_direction(self, direction): 

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

1064 if self.cg_init: 

1065 self.cg_init = False 

1066 self.direction_old = direction.copy() 

1067 self.cg_direction = direction.copy() 

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

1069 # Polak-Ribiere Conjugate Gradient 

1070 if old_norm != 0.0: 

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

1072 old_norm 

1073 else: 

1074 betaPR = 0.0 

1075 if betaPR < 0.0: 

1076 betaPR = 0.0 

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

1078 self.direction_old = direction.copy() 

1079 return self.cg_direction.copy() 

1080 

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

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

1083 f = self.dimeratoms.get_forces() 

1084 if self.logfile is not None: 

1085 T = time.localtime() 

1086 e = self.dimeratoms.get_potential_energy() 

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

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

1089 curvature = self.dimeratoms.get_curvature() 

1090 l = '' 

1091 if stepsize: 

1092 if isinstance(self.control, DimerControl): 

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

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

1095 'MinModeTranslate', self.nsteps, 

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

1097 rotsteps) 

1098 else: 

1099 if isinstance(self.control, DimerControl): 

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

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

1102 'MinModeTranslate', self.nsteps, 

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

1104 curvature, rotsteps) 

1105 self.logfile.write(l) 

1106 self.logfile.flush() 

1107 

1108 

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

1110 """Read an eigenmode. 

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

1112 

1113 """ 

1114 mlog_is_str = isinstance(mlog, str) 

1115 if mlog_is_str: 

1116 fd = open(mlog) 

1117 else: 

1118 fd = mlog 

1119 

1120 lines = fd.readlines() 

1121 

1122 # Detect the amount of atoms and iterations 

1123 k = 2 

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

1125 k += 1 

1126 n = k - 2 

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

1128 

1129 # Locate the correct image. 

1130 if isinstance(index, str): 

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

1132 i = 0 

1133 else: 

1134 i = int(index) + 1 

1135 else: 

1136 if index >= 0: 

1137 i = index + 1 

1138 else: 

1139 if index < -n_itr - 1: 

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

1141 else: 

1142 i = index 

1143 

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

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

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

1147 for k_dim in range(3): 

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

1149 if mlog_is_str: 

1150 fd.close() 

1151 

1152 return mode 

1153 

1154 

1155# Aliases 

1156DimerAtoms = MinModeAtoms 

1157DimerTranslate = MinModeTranslate