Coverage for /builds/debichem-team/python-ase/ase/mep/dimer.py: 71.99%

589 statements  

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

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

2 

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

4""" 

5 

6import sys 

7import time 

8import warnings 

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

10from typing import Any, Dict 

11 

12import numpy as np 

13 

14from ase.calculators.singlepoint import SinglePointCalculator 

15from ase.optimize.optimize import OptimizableAtoms, Optimizer 

16from ase.parallel import world 

17from ase.utils import IOContext 

18 

19# Handy vector methods 

20norm = np.linalg.norm 

21 

22 

23class DimerOptimizable(OptimizableAtoms): 

24 def __init__(self, dimeratoms): 

25 self.dimeratoms = dimeratoms 

26 super().__init__(dimeratoms) 

27 

28 def converged(self, forces, fmax): 

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

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

31 

32 

33def normalize(vector): 

34 """Create a unit vector along *vector*""" 

35 return vector / norm(vector) 

36 

37 

38def parallel_vector(vector, base): 

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

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

41 

42 

43def perpendicular_vector(vector, base): 

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

45 return vector - parallel_vector(vector, base) 

46 

47 

48def rotate_vectors(v1i, v2i, angle): 

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

50 cAng = cos(angle) 

51 sAng = sin(angle) 

52 v1o = v1i * cAng + v2i * sAng 

53 v2o = v2i * cAng - v1i * sAng 

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

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

56 

57 

58class DimerEigenmodeSearch: 

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

60 

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

62 searching method. 

63 

64 Parameters: 

65 

66 atoms: MinModeAtoms object 

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

68 information about the lowest eigenvalue mode. 

69 control: DimerControl object 

70 Contains the parameters necessary for the eigenmode search. 

71 If no control object is supplied a default DimerControl 

72 will be created and used. 

73 basis: list of xyz-values 

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

75 It is possible to constrain the eigenmodes to be orthogonal 

76 to this given eigenmode. 

77 

78 Notes: 

79 

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

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

82 

83 References: 

84 

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

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

87 9776 (2004). 

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

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

90 

91 """ 

92 

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

94 **kwargs): 

95 if hasattr(dimeratoms, 'get_eigenmode'): 

96 self.dimeratoms = dimeratoms 

97 else: 

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

99 raise TypeError(e) 

100 self.basis = basis 

101 

102 if eigenmode is None: 

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

104 else: 

105 self.eigenmode = eigenmode 

106 

107 if control is None: 

108 self.control = DimerControl(**kwargs) 

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

110 '. Using default: DimerControl()' 

111 warnings.warn(w, UserWarning) 

112 if self.control.logfile is not None: 

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

114 self.control.logfile.flush() 

115 else: 

116 self.control = control 

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

118 for key in kwargs: 

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

120 raise TypeError(e) 

121 

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

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

124 

125 def converge_to_eigenmode(self): 

126 """Perform an eigenmode search.""" 

127 self.set_up_for_eigenmode_search() 

128 stoprot = False 

129 

130 # Load the relevant parameters from control 

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

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

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

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

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

136 

137 while not stoprot: 

138 if self.forces1E is None: 

139 self.update_virtual_forces() 

140 else: 

141 self.update_virtual_forces(extrapolated_forces=True) 

142 self.forces1A = self.forces1 

143 self.update_curvature() 

144 f_rot_A = self.get_rotational_force() 

145 

146 # Pre rotation stop criteria 

147 if norm(f_rot_A) <= f_rot_min: 

148 self.log(f_rot_A, None) 

149 stoprot = True 

150 else: 

151 n_A = self.eigenmode 

152 rot_unit_A = normalize(f_rot_A) 

153 

154 # Get the curvature and its derivative 

155 c0 = self.get_curvature() 

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

157 self.dR 

158 

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

160 # NYI variable trial angles from [3] 

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

162 self.eigenmode = n_B 

163 self.update_virtual_forces() 

164 self.forces1B = self.forces1 

165 

166 # Get the curvature's derivative 

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

168 self.dR 

169 

170 # Calculate the Fourier coefficients 

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

172 (2 * sin(2 * trial_angle)) 

173 b1 = 0.5 * c0d 

174 a0 = 2 * (c0 - a1) 

175 

176 # Estimate the rotational angle 

177 rotangle = atan(b1 / a1) / 2.0 

178 

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

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

181 b1 * sin(2 * rotangle) 

182 if c0 < cmin: 

183 rotangle += pi / 2.0 

184 

185 # Rotate into the (hopefully) lowest eigenmode 

186 # NYI Conjugate gradient rotation 

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

188 self.update_eigenmode(n_min) 

189 

190 # Store the curvature estimate instead of the old curvature 

191 self.update_curvature(cmin) 

192 

193 self.log(f_rot_A, rotangle) 

194 

195 # Force extrapolation scheme from [4] 

196 if extrapolate: 

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

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

199 sin(trial_angle) * self.forces1B + \ 

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

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

202 else: 

203 self.forces1E = None 

204 

205 # Post rotation stop criteria 

206 if not stoprot: 

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

208 stoprot = True 

209 elif norm(f_rot_A) <= f_rot_max: 

210 stoprot = True 

211 

212 def log(self, f_rot_A, angle): 

213 """Log each rotational step.""" 

214 # NYI Log for the trial angle 

215 if self.logfile is not None: 

216 if angle: 

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

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

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

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

221 else: 

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

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

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

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

226 self.logfile.write(l) 

227 self.logfile.flush() 

228 

229 def get_rotational_force(self): 

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

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

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

233 if self.basis is not None: 

234 if ( 

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

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

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

238 rot_force = perpendicular_vector(rot_force, self.basis) 

239 else: 

240 for base in self.basis: 

241 rot_force = perpendicular_vector(rot_force, base) 

242 return rot_force 

243 

244 def update_curvature(self, curv=None): 

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

246 if curv: 

247 self.curvature = curv 

248 else: 

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

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

251 

252 def update_eigenmode(self, eigenmode): 

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

254 self.eigenmode = eigenmode 

255 self.update_virtual_positions() 

256 self.control.increment_counter('rotcount') 

257 

258 def get_eigenmode(self): 

259 """Returns the current eigenmode.""" 

260 return self.eigenmode 

261 

262 def get_curvature(self): 

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

264 return self.curvature 

265 

266 def get_control(self): 

267 """Return the control object.""" 

268 return self.control 

269 

270 def update_center_forces(self): 

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

272 self.dimeratoms.set_positions(self.pos0) 

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

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

275 

276 def update_virtual_forces(self, extrapolated_forces=False): 

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

278 self.update_virtual_positions() 

279 

280 # Estimate / Calculate the forces at pos1 

281 if extrapolated_forces: 

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

283 else: 

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

285 

286 # Estimate / Calculate the forces at pos2 

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

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

289 else: 

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

291 

292 def update_virtual_positions(self): 

293 """Update the end point positions.""" 

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

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

296 

297 def set_up_for_eigenmode_search(self): 

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

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

300 self.update_center_forces() 

301 self.update_virtual_positions() 

302 self.control.reset_counter('rotcount') 

303 self.forces1E = None 

304 

305 def set_up_for_optimization_step(self): 

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

307 self.dimeratoms.set_positions(self.pos0) 

308 self.forces1E = None 

309 

310 

311class MinModeControl(IOContext): 

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

313 

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

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

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

317 parameters needed by the method in question. 

318 When instantiating control classes default parameter values can 

319 be overwritten. 

320 

321 """ 

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

323 

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

325 **kwargs): 

326 # Overwrite the defaults with the input parameters given 

327 for key in kwargs: 

328 if key not in self.parameters: 

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

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

331 raise ValueError(e) 

332 else: 

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

334 

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

336 eigenmode_logfile=eigenmode_logfile) 

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

338 self.log() 

339 

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

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

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

343 comm=comm) 

344 

345 def log(self, parameter=None): 

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

347 

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

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

350 if parameter not in self.parameters: 

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

352 raise ValueError(e) 

353 self.parameters[parameter] = value 

354 if log: 

355 self.log(parameter) 

356 

357 def get_parameter(self, parameter): 

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

359 if parameter not in self.parameters: 

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

361 raise ValueError(e) 

362 return self.parameters[parameter] 

363 

364 def get_logfile(self): 

365 """Returns the log file.""" 

366 return self.logfile 

367 

368 def get_eigenmode_logfile(self): 

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

370 return self.eigenmode_logfile 

371 

372 def get_counter(self, counter): 

373 """Returns a given counter.""" 

374 return self.counters[counter] 

375 

376 def increment_counter(self, counter): 

377 """Increment a given counter.""" 

378 self.counters[counter] += 1 

379 

380 def reset_counter(self, counter): 

381 """Reset a given counter.""" 

382 self.counters[counter] = 0 

383 

384 def reset_all_counters(self): 

385 """Reset all counters.""" 

386 for key in self.counters: 

387 self.counters[key] = 0 

388 

389 

390class DimerControl(MinModeControl): 

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

392 

393 Parameters: 

394 

395 eigenmode_method: str 

396 The name of the eigenmode search method. 

397 f_rot_min: float 

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

399 performed. 

400 f_rot_max: float 

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

402 performed. 

403 max_num_rot: int 

404 Maximum number of rotations per optimizer step. 

405 trial_angle: float 

406 Trial angle for the finite difference estimate of the rotational 

407 angle in radians. 

408 trial_trans_step: float 

409 Trial step size for the MinModeTranslate optimizer. 

410 maximum_translation: float 

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

412 positive for the MinModeTranslate optimizer. 

413 cg_translation: bool 

414 Conjugate Gradient for the MinModeTranslate optimizer. 

415 use_central_forces: bool 

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

417 the forces to the other. 

418 dimer_separation: float 

419 Separation of the dimer's images. 

420 initial_eigenmode_method: str 

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

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

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

424 extrapolate_forces: bool 

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

426 be used to reduce the number of force evaluations. 

427 displacement_method: str 

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

429 gauss_std: float 

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

431 displacement. 

432 order: int 

433 How many lowest eigenmodes will be inverted. 

434 mask: list of bool 

435 Which atoms will be moved during displacement. 

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

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

438 displacement_radius: float 

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

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

441 number_of_displacement_atoms: int 

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

443 

444 """ 

445 # Default parameters for the Dimer eigenmode search 

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

447 'f_rot_min': 0.1, 

448 'f_rot_max': 1.00, 

449 'max_num_rot': 1, 

450 'trial_angle': pi / 4.0, 

451 'trial_trans_step': 0.001, 

452 'maximum_translation': 0.1, 

453 'cg_translation': True, 

454 'use_central_forces': True, 

455 'dimer_separation': 0.0001, 

456 'initial_eigenmode_method': 'gauss', 

457 'extrapolate_forces': False, 

458 'displacement_method': 'gauss', 

459 'gauss_std': 0.1, 

460 'order': 1, 

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

462 'displacement_center': None, 

463 'displacement_radius': None, 

464 'number_of_displacement_atoms': None} 

465 

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

467 def log(self, parameter=None): 

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

469 if self.logfile is not None: 

470 if parameter is not None: 

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

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

473 else: 

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

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

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

477 for key in self.parameters: 

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

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

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

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

482 'ROT-FORCE\n' 

483 self.logfile.write(l) 

484 self.logfile.flush() 

485 

486 

487class MinModeAtoms: 

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

489 

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

491 object. 

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

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

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

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

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

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

498 a saddle point. 

499 

500 Parameters: 

501 

502 atoms : Atoms object 

503 A regular Atoms object 

504 control : MinModeControl object 

505 Contains the parameters necessary for the eigenmode search. 

506 If no control object is supplied a default DimerControl 

507 will be created and used. 

508 mask: list of bool 

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

510 random_seed: int 

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

512 modified version the current time. 

513 

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

515 

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

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

518 9776 (2004). 

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

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

521 

522 """ 

523 

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

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

526 self.minmode_init = True 

527 self.atoms = atoms 

528 

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

530 self.eigenmodes = eigenmodes 

531 self.curvatures = None 

532 

533 if control is None: 

534 self.control = DimerControl(**kwargs) 

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

536 '. Using default: DimerControl()' 

537 warnings.warn(w, UserWarning) 

538 if self.control.logfile is not None: 

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

540 self.control.logfile.flush() 

541 else: 

542 self.control = control 

543 logfile = self.control.get_logfile() 

544 mlogfile = self.control.get_eigenmode_logfile() 

545 for key in kwargs: 

546 if key == 'logfile': 

547 logfile = kwargs[key] 

548 elif key == 'eigenmode_logfile': 

549 mlogfile = kwargs[key] 

550 else: 

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

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

553 eigenmode_logfile=mlogfile) 

554 

555 # Seed the randomness 

556 if random_seed is None: 

557 t = time.time() 

558 if world.size > 1: 

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

560 # Harvest the latter part of the current time 

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

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

563 

564 # Check the order 

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

566 

567 # Construct the curvatures list 

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

569 

570 # Save the original state of the atoms. 

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

572 self.save_original_forces() 

573 

574 # Get a reference to the log files 

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

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

577 

578 def __ase_optimizable__(self): 

579 return DimerOptimizable(self) 

580 

581 def save_original_forces(self, force_calculation=False): 

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

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

584 if self.calc is not None: 

585 # Hack because some calculators do not have calculation_required 

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

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

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

589 calc = SinglePointCalculator( 

590 self.atoms0, 

591 energy=self.atoms.get_potential_energy(), 

592 forces=self.atoms.get_forces()) 

593 self.atoms0.calc = calc 

594 

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

596 gauss_std=None): 

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

598 if eigenmodes is None: 

599 pos = self.get_positions() 

600 old_pos = self.get_original_positions() 

601 if method is None: 

602 method = \ 

603 self.control.get_parameter('initial_eigenmode_method') 

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

605 eigenmode = normalize(pos - old_pos) 

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

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

608 method=method) 

609 new_pos = self.get_positions() 

610 eigenmode = normalize(new_pos - pos) 

611 self.set_positions(pos) 

612 else: 

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

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

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

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

617 raise NotImplementedError(e) # NYI 

618 eigenmodes = [eigenmode] 

619 

620 # Create random higher order mode guesses 

621 if self.order > 1: 

622 if len(eigenmodes) == 1: 

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

624 pos = self.get_positions() 

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

626 method=method) 

627 new_pos = self.get_positions() 

628 eigenmode = normalize(new_pos - pos) 

629 self.set_positions(pos) 

630 eigenmodes += [eigenmode] 

631 

632 self.eigenmodes = eigenmodes 

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

634 if self.order > 1: 

635 for k in range(self.order): 

636 self.ensure_eigenmode_orthogonality(k) 

637 self.eigenmode_log() 

638 

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

640 # calc.calculation_required() 

641 def calculation_required(self): 

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

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

644 

645 def calculate_real_forces_and_energies(self, **kwargs): 

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

647 if self.minmode_init: 

648 self.minmode_init = False 

649 self.initialize_eigenmodes(eigenmodes=self.eigenmodes) 

650 self.rotation_required = True 

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

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

653 self.control.increment_counter('forcecalls') 

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

655 

656 def get_potential_energy(self): 

657 """Return the potential energy.""" 

658 if self.calculation_required(): 

659 self.calculate_real_forces_and_energies() 

660 return self.energy0 

661 

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

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

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

665 self.calculate_real_forces_and_energies(**kwargs) 

666 if real and pos is None: 

667 return self.forces0 

668 elif real and pos is not None: 

669 old_pos = self.atoms.get_positions() 

670 self.atoms.set_positions(pos) 

671 forces = self.atoms.get_forces() 

672 self.control.increment_counter('forcecalls') 

673 self.atoms.set_positions(old_pos) 

674 return forces 

675 else: 

676 if self.rotation_required: 

677 self.find_eigenmodes(order=self.order) 

678 self.eigenmode_log() 

679 self.rotation_required = False 

680 self.control.increment_counter('optcount') 

681 return self.get_projected_forces() 

682 

683 def ensure_eigenmode_orthogonality(self, order): 

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

685 for k in range(order - 1): 

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

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

688 

689 def find_eigenmodes(self, order=1): 

690 """Launch eigenmode searches.""" 

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

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

693 raise NotImplementedError(e) # NYI 

694 for k in range(order): 

695 if k > 0: 

696 self.ensure_eigenmode_orthogonality(k + 1) 

697 search = DimerEigenmodeSearch( 

698 self, self.control, 

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

700 search.converge_to_eigenmode() 

701 search.set_up_for_optimization_step() 

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

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

704 

705 def get_projected_forces(self, pos=None): 

706 """Return the projected forces.""" 

707 if pos is not None: 

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

709 else: 

710 forces = self.forces0.copy() 

711 

712 # Loop through all the eigenmodes 

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

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

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

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

717 forces = -parallel_vector(forces, mode) 

718 else: 

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

720 return forces 

721 

722 def restore_original_positions(self): 

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

724 self.atoms.set_positions(self.get_original_positions()) 

725 

726 def get_barrier_energy(self): 

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

728 try: 

729 original_energy = self.get_original_potential_energy() 

730 dimer_energy = self.get_potential_energy() 

731 return dimer_energy - original_energy 

732 except RuntimeError: 

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

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

735 warnings.warn(w, UserWarning) 

736 return np.nan 

737 

738 def get_control(self): 

739 """Return the control object.""" 

740 return self.control 

741 

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

743 """Return the eigenvalue estimate.""" 

744 if order == 'max': 

745 return max(self.curvatures) 

746 else: 

747 return self.curvatures[order - 1] 

748 

749 def get_eigenmode(self, order=1): 

750 """Return the current eigenmode guess.""" 

751 return self.eigenmodes[order - 1] 

752 

753 def get_atoms(self): 

754 """Return the unextended Atoms object.""" 

755 return self.atoms 

756 

757 def set_atoms(self, atoms): 

758 """Set a new Atoms object""" 

759 self.atoms = atoms 

760 

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

762 """Set the eigenmode guess.""" 

763 self.eigenmodes[order - 1] = eigenmode 

764 

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

766 """Set the eigenvalue estimate.""" 

767 self.curvatures[order - 1] = curvature 

768 

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

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

771 def __getattr__(self, attr): 

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

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

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

775 return getattr(self.atoms0, attr) 

776 else: 

777 return getattr(self.atoms, attr) 

778 

779 def __len__(self): 

780 return len(self.atoms) 

781 

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

783 displacement_center=None, radius=None, number_of_atoms=None, 

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

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

786 

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

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

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

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

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

792 

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

794 to perform a predefined displacement. 

795 

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

797 used for random displacement. 

798 

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

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

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

802 the closest atoms will be displaced. 

803 

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

805 

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

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

808 within reach of the *displacement_center*. 

809 

810 The parameters priority order: 

811 1) displacement_vector 

812 2) mask 

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

814 

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

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

817 be displaced. 

818 

819 """ 

820 

821 # Fetch the default values from the control 

822 if mask is None: 

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

824 if method is None: 

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

826 if gauss_std is None: 

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

828 if displacement_center is None: 

829 displacement_center = \ 

830 self.control.get_parameter('displacement_center') 

831 if radius is None: 

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

833 if number_of_atoms is None: 

834 number_of_atoms = \ 

835 self.control.get_parameter('number_of_displacement_atoms') 

836 

837 # Check for conflicts 

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

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

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

841 raise ValueError(e) 

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

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

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

845 raise ValueError(e) 

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

847 number_of_atoms is None: 

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

849 'number_of_atoms must be supplied.\n' 

850 raise ValueError(e) 

851 

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

853 if displacement_center is not None: 

854 c = displacement_center 

855 # Construct a distance list 

856 # The center is an atom 

857 if isinstance(c, int): 

858 # Parse negative indexes 

859 c = displacement_center % len(self) 

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

861 range(len(self))] 

862 # The center is a position in 3D space 

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

864 # NB: MIC is not considered. 

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

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

867 else: 

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

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

870 '(3-tuple of floats).' 

871 raise ValueError(e) 

872 

873 # Set up the mask 

874 if radius is not None: 

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

876 else: 

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

878 

879 if number_of_atoms is not None: 

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

881 n_nearest = d_sorted[:number_of_atoms] 

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

883 else: 

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

885 

886 # Resolve n_mask / r_mask conflicts 

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

888 else: 

889 c_mask = None 

890 

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

892 if mask is None: 

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

894 if c_mask is None: 

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

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

897 warnings.warn(w, UserWarning) 

898 if self.logfile is not None: 

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

900 self.logfile.flush() 

901 

902 # Resolve mask / c_mask conflicts 

903 if c_mask is not None: 

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

905 

906 if displacement_vector is None: 

907 displacement_vector = [] 

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

909 if mask[k]: 

910 diff_line = [] 

911 for _ in range(3): 

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

913 if not gauss_std: 

914 gauss_std = \ 

915 self.control.get_parameter('gauss_std') 

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

917 else: 

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

919 raise ValueError(e) 

920 diff_line.append(diff) 

921 displacement_vector.append(diff_line) 

922 else: 

923 displacement_vector.append([0.0] * 3) 

924 

925 # Remove displacement of masked atoms 

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

927 if not mask[k]: 

928 displacement_vector[k] = [0.0] * 3 

929 

930 # Perform the displacement and log it 

931 if log: 

932 pos0 = self.get_positions() 

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

934 if log: 

935 parameters = {'mask': mask, 

936 'displacement_method': method, 

937 'gauss_std': gauss_std, 

938 'displacement_center': displacement_center, 

939 'displacement_radius': radius, 

940 'number_of_displacement_atoms': number_of_atoms} 

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

942 

943 def eigenmode_log(self): 

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

945 if self.mlogfile is not None: 

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

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

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

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

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

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

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

953 self.mlogfile.write(l) 

954 self.mlogfile.flush() 

955 

956 def displacement_log(self, displacement_vector, parameters): 

957 """Log the displacement""" 

958 if self.logfile is not None: 

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

960 mod_para = False 

961 for key in parameters: 

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

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

964 str(parameters[key])) 

965 mod_para = True 

966 if mod_para: 

967 l = lp 

968 else: 

969 l = '' 

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

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

972 k, 

973 displacement_vector[k][0], displacement_vector[k][1], 

974 displacement_vector[k][2]) 

975 self.logfile.write(l) 

976 self.logfile.flush() 

977 

978 def summarize(self): 

979 """Summarize the Minimum mode search.""" 

980 if self.logfile is None: 

981 logfile = sys.stdout 

982 else: 

983 logfile = self.logfile 

984 

985 c = self.control 

986 label = 'MINMODE:SUMMARY: ' 

987 

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

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

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

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

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

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

994 

995 logfile.write(l) 

996 

997 

998class MinModeTranslate(Optimizer): 

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

1000 

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

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

1003 

1004 self.control = dimeratoms.get_control() 

1005 self.dimeratoms = dimeratoms 

1006 

1007 # Make a header for the log 

1008 if self.logfile is not None: 

1009 l = '' 

1010 if isinstance(self.control, DimerControl): 

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

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

1013 self.logfile.write(l) 

1014 self.logfile.flush() 

1015 

1016 # Load the relevant parameters from control 

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

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

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

1020 

1021 # Start conjugate gradient 

1022 if self.cg_on: 

1023 self.cg_init = True 

1024 

1025 def initialize(self): 

1026 """Set initial values.""" 

1027 self.r0 = None 

1028 self.f0 = None 

1029 

1030 def step(self, f=None): 

1031 """Perform the optimization step.""" 

1032 atoms = self.dimeratoms 

1033 if f is None: 

1034 f = atoms.get_forces() 

1035 r = atoms.get_positions() 

1036 curv = atoms.get_curvature() 

1037 f0p = f.copy() 

1038 r0 = r.copy() 

1039 direction = f0p.copy() 

1040 if self.cg_on: 

1041 direction = self.get_cg_direction(direction) 

1042 direction = normalize(direction) 

1043 if curv > 0.0: 

1044 step = direction * self.max_step 

1045 else: 

1046 r0t = r0 + direction * self.trial_step 

1047 f0tp = self.dimeratoms.get_projected_forces(r0t) 

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

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

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

1051 if norm(step) > self.max_step: 

1052 step = direction * self.max_step 

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

1054 

1055 atoms.set_positions(r + step) 

1056 

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

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

1059 

1060 def get_cg_direction(self, direction): 

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

1062 if self.cg_init: 

1063 self.cg_init = False 

1064 self.direction_old = direction.copy() 

1065 self.cg_direction = direction.copy() 

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

1067 # Polak-Ribiere Conjugate Gradient 

1068 if old_norm != 0.0: 

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

1070 old_norm 

1071 else: 

1072 betaPR = 0.0 

1073 if betaPR < 0.0: 

1074 betaPR = 0.0 

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

1076 self.direction_old = direction.copy() 

1077 return self.cg_direction.copy() 

1078 

1079 def log(self, f=None, stepsize=None): 

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

1081 if f is None: 

1082 f = self.dimeratoms.get_forces() 

1083 if self.logfile is not None: 

1084 T = time.localtime() 

1085 e = self.dimeratoms.get_potential_energy() 

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

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

1088 curvature = self.dimeratoms.get_curvature() 

1089 l = '' 

1090 if stepsize: 

1091 if isinstance(self.control, DimerControl): 

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

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

1094 'MinModeTranslate', self.nsteps, 

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

1096 rotsteps) 

1097 else: 

1098 if isinstance(self.control, DimerControl): 

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

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

1101 'MinModeTranslate', self.nsteps, 

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

1103 curvature, rotsteps) 

1104 self.logfile.write(l) 

1105 self.logfile.flush() 

1106 

1107 

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

1109 """Read an eigenmode. 

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

1111 

1112 """ 

1113 mlog_is_str = isinstance(mlog, str) 

1114 if mlog_is_str: 

1115 fd = open(mlog) 

1116 else: 

1117 fd = mlog 

1118 

1119 lines = fd.readlines() 

1120 

1121 # Detect the amount of atoms and iterations 

1122 k = 2 

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

1124 k += 1 

1125 n = k - 2 

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

1127 

1128 # Locate the correct image. 

1129 if isinstance(index, str): 

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

1131 i = 0 

1132 else: 

1133 i = int(index) + 1 

1134 else: 

1135 if index >= 0: 

1136 i = index + 1 

1137 else: 

1138 if index < -n_itr - 1: 

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

1140 else: 

1141 i = index 

1142 

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

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

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

1146 for k_dim in range(3): 

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

1148 if mlog_is_str: 

1149 fd.close() 

1150 

1151 return mode 

1152 

1153 

1154# Aliases 

1155DimerAtoms = MinModeAtoms 

1156DimerTranslate = MinModeTranslate